From 342a348c4474a008760336efa88dd4734c1790c1 Mon Sep 17 00:00:00 2001 From: LiavTB <49226046+LiavTB@users.noreply.github.com> Date: Fri, 23 May 2025 15:58:49 +0300 Subject: [PATCH 01/23] Changed some structure created the resume model and types/resume_types.ts TODO - connect to a resume upload and parsing in the server, integrate with the client --- .gitignore | 1 + nextstep-backend/src/models/resume_model.ts | 36 +++++ .../src/services/resume_service.ts | 140 ++---------------- nextstep-backend/src/types/resume_types.ts | 31 ++++ .../resume_handlers/resume_AI_handler.ts | 84 +++++++++++ .../resume_handlers/resume_files_handler.ts | 49 ++++++ 6 files changed, 211 insertions(+), 130 deletions(-) create mode 100644 nextstep-backend/src/models/resume_model.ts create mode 100644 nextstep-backend/src/types/resume_types.ts create mode 100644 nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts create mode 100644 nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts 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/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts new file mode 100644 index 0000000..de0f10d --- /dev/null +++ b/nextstep-backend/src/models/resume_model.ts @@ -0,0 +1,36 @@ +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: { + aboutMe: { type: String, required: false }, + skills: { type: [String], required: false }, + roleMatch: { type: String, required: false }, + experience: { type: [String], 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 default mongoose.model('Resume', ResumeSchema); \ 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 dd33ed0..d918f91 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -1,105 +1,22 @@ 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 } 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'; -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 scoreResume = async (resumePath: string, jobDescription?: string): Promise<{ score: number; feedback: string }> => { try { @@ -295,36 +212,11 @@ Content: ${content}`; }).join('\n\n'); // Prepare the prompt for AI to modify the content - const prompt = `You are a resume expert. Please modify the following resume content based on the feedback and job description. - -Current Resume Content: -${readableContent} - -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`; + const prompt = createResumeModificationPrompt(readableContent, feedback, jobDescription); // Get the modified content from AI const modifiedContent = await chatWithAI(SYSTEM_TEMPLATE, [prompt]); - console.log('AI Response:', modifiedContent); // Debug log + console.debug('AI Response:', modifiedContent); // Debug log let modifiedParagraphs; try { @@ -416,23 +308,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] ); diff --git a/nextstep-backend/src/types/resume_types.ts b/nextstep-backend/src/types/resume_types.ts new file mode 100644 index 0000000..75791b3 --- /dev/null +++ b/nextstep-backend/src/types/resume_types.ts @@ -0,0 +1,31 @@ +import { Document } from 'mongoose'; + + +export interface ParsedResume { + aboutMe: string; + skills: string[]; + roleMatch: string; + experience: string[]; + education?: 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/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'); + } +}; + + From 3d9865657ed012f4be17f5f3ac8b145d5f0ab1d0 Mon Sep 17 00:00:00 2001 From: LiavTB <49226046+LiavTB@users.noreply.github.com> Date: Sun, 1 Jun 2025 15:24:27 +0300 Subject: [PATCH 02/23] saved to the parsed resume in the db --- .../src/controllers/resources_controller.ts | 4 +- .../src/controllers/resume_controller.ts | 27 ++++++- nextstep-backend/src/models/resume_model.ts | 2 +- nextstep-backend/src/routes/resume_routes.ts | 7 +- .../src/services/resume_service.ts | 75 ++++++++++++++++++- 5 files changed, 108 insertions(+), 7 deletions(-) diff --git a/nextstep-backend/src/controllers/resources_controller.ts b/nextstep-backend/src/controllers/resources_controller.ts index c59a52b..b547f2c 100644 --- a/nextstep-backend/src/controllers/resources_controller.ts +++ b/nextstep-backend/src/controllers/resources_controller.ts @@ -50,7 +50,9 @@ const getImageResource = async (req: Request, res: Response) => { 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) { diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 27dee76..ef65466 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -2,7 +2,9 @@ import { Request, Response } from 'express'; import { config } from '../config/config'; import fs from 'fs'; import path from 'path'; -import { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields } from '../services/resume_service'; +import { scoreResume, streamScoreResume, getResumeTemplates, + generateImprovedResume, parseResumeFields, + saveParsedResume, getResumeByOwner } from '../services/resume_service'; import multer from 'multer'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; @@ -94,12 +96,15 @@ const generateResume = async (req: Request, res: Response) => { }; -const parseResume = async (req: Request, res: Response) => { +const parseResume = async (req: CustomRequest, res: Response) => { try { if (!req.file) { return res.status(400).json({ error: 'No resume file uploaded' }); } + const resumeFilename = await uploadResume(req); const parsed = await parseResumeFields(req.file.buffer, req.file.originalname); + const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename); + return res.status(200).json(parsed); } catch (err: any) { console.error('Error parsing resume:', err); @@ -107,4 +112,20 @@ const parseResume = async (req: Request, res: Response) => { } }; -export default { parseResume, getResumeScore, getStreamResumeScore, getTemplates, generateResume }; \ No newline at end of file +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(resumeData); + } catch (error) { + console.error('Error retrieving resume data:', error); + return handleError(error, res); + } +}; + +export default { parseResume, getResumeScore, + getStreamResumeScore, getTemplates, + generateResume, getResumeData }; \ No newline at end of file diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts index de0f10d..4a3499a 100644 --- a/nextstep-backend/src/models/resume_model.ts +++ b/nextstep-backend/src/models/resume_model.ts @@ -33,4 +33,4 @@ ResumeSchema.set('toJSON', { } }); -export default mongoose.model('Resume', ResumeSchema); \ No newline at end of file +export const ResumeModel = mongoose.model('Resume', ResumeSchema); \ No newline at end of file diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index b365b02..9bb93bd 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(); @@ -15,6 +16,10 @@ router.get('/templates', Resume.getTemplates); router.post('/generate', Resume.generateResume); -router.post('/parseResume', upload.single('resume'), Resume.parseResume); +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)) + export default router; \ 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 d918f91..6e4a027 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -9,6 +9,8 @@ import { DOMParser, XMLSerializer } from 'xmldom'; import { ParsedResume } 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 {PostModel} from "../models/posts_model"; @@ -17,6 +19,11 @@ import { parseDocument } from '../utils/resume_handlers/resume_files_handler'; const FEEDBACK_ERROR_MESSAGE = 'The Chat AI feature is turned off. Could not score your resume.'; +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 }> => { try { @@ -321,4 +328,70 @@ const parseResumeFields = async ( return parsed; }; -export { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, 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): Promise => { + const lastVersion = await getLatestResumeByUser(ownerId); + const newVersion = lastVersion + 1; + + const newResume = new ResumeModel({ + owner: ownerId, + version: newVersion, + rawContentLink: resumeRawLink, + parsedData: { + aboutMe: parsedData.aboutMe, + skills: parsedData.skills, + roleMatch: parsedData.roleMatch, + experience: parsedData.experience + } + }); + + const savedResume = await newResume.save(); + return resumeToResumeData(savedResume); +}; + + +const ysgetResumeByOwner = async (ownerId: string, version?: number) => { + try { + let query = { + 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) { + throw new Error(version !== undefined + ? `Resume version ${version} not found for user ${ownerId}` + : `No resume found for user ${ownerId}`); + } + + return resume; + } catch (error) { + console.error('Error retrieving resume:', error); + throw error; + } +}; + +export { scoreResume, streamScoreResume, + getResumeTemplates, generateImprovedResume, parseResumeFields, + saveParsedResume, getResumeByOwner }; \ No newline at end of file From c1bcbfdfbe0b5ec713d49f7ac9d90e3b7306a8f8 Mon Sep 17 00:00:00 2001 From: LiavTB <49226046+LiavTB@users.noreply.github.com> Date: Sun, 1 Jun 2025 15:44:04 +0300 Subject: [PATCH 03/23] fix types errors --- nextstep-backend/src/controllers/resume_controller.ts | 3 ++- nextstep-backend/src/services/posts_service.ts | 5 +++-- nextstep-backend/src/services/resume_service.ts | 10 +++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index ef65466..67bb1d2 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -6,6 +6,7 @@ import { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields, saveParsedResume, getResumeByOwner } from '../services/resume_service'; import multer from 'multer'; +import { uploadResume } from '../services/resources_service'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; @@ -119,7 +120,7 @@ const getResumeData = async (req: CustomRequest, res: Response) => { const version = req.query.version ? parseInt(req.query.version as string) : undefined; const resume = await getResumeByOwner(ownerId, version); - return res.status(200).json(resumeData); + return res.status(200).json(resume); } catch (error) { console.error('Error retrieving resume data:', error); return handleError(error, res); 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/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index 6e4a027..1a22f8b 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -6,11 +6,11 @@ import mammoth from 'mammoth'; import pdfParse from 'pdf-parse'; import AdmZip from 'adm-zip'; import { DOMParser, XMLSerializer } from 'xmldom'; -import { ParsedResume } from 'types/resume_types'; +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 {PostModel} from "../models/posts_model"; +import {Document} from 'mongoose'; @@ -364,10 +364,10 @@ const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resum }; -const ysgetResumeByOwner = async (ownerId: string, version?: number) => { +const getResumeByOwner = async (ownerId: string, version?: number) => { try { - let query = { - owner: ownerId + let query: {owner: string, version?: number} = { + owner: ownerId, }; // If version is specified, add it to the query From e94ba1fd505915661981d35a91e6e2eabe81c65f Mon Sep 17 00:00:00 2001 From: LiavTB <49226046+LiavTB@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:38:55 +0300 Subject: [PATCH 04/23] upload the resume using the resource upload route Handle the saving of the parsed resume data --- .../src/controllers/resume_controller.ts | 12 +++++--- .../src/services/resources_service.ts | 30 ++++++++++++++++++- nextstep-frontend/src/pages/MainDashboard.tsx | 21 +++++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 67bb1d2..8adbf77 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -6,7 +6,7 @@ import { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields, saveParsedResume, getResumeByOwner } from '../services/resume_service'; import multer from 'multer'; -import { uploadResume } from '../services/resources_service'; +import {getResumeBuffer, resumeExists, uploadResume} from '../services/resources_service'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; @@ -99,11 +99,15 @@ const generateResume = 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 resumeFilename = await uploadResume(req); - 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); return res.status(200).json(parsed); diff --git a/nextstep-backend/src/services/resources_service.ts b/nextstep-backend/src/services/resources_service.ts index 8370c26..5907a39 100644 --- a/nextstep-backend/src/services/resources_service.ts +++ b/nextstep-backend/src/services/resources_service.ts @@ -123,4 +123,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 }; \ No newline at end of file diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 45012f6..2aa9f2d 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -141,15 +141,30 @@ const MainDashboard: React.FC = () => { // Upload & parse resume const handleResumeUpload = async (e: React.ChangeEvent) => { + + 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('resume', file); + form.append('file', file); try { - const res = await api.post('/resume/parseResume', form, { - headers: { 'Content-Type': 'multipart/form-data' } + const uplaodedResume = await uploadResume(form); + + const res = await api.post('/resume/parseResume', + { + resumefileName: uplaodedResume, + }, { + headers: { 'Content-Type': 'multipart/form-data' }, }); const { aboutMe: aiAbout, skills: aiSkills, roleMatch: aiRole, experience: aiExp } = res.data; setAboutMe(aiAbout); From 5044047beeaf35eae1e293bfc418b891f8210f4b Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 7 Jun 2025 12:05:50 +0300 Subject: [PATCH 05/23] added get resume by owner & added filename to resume model --- .../src/controllers/resume_controller.ts | 21 +++++++++++++++++-- nextstep-backend/src/models/resume_model.ts | 2 +- nextstep-backend/src/routes/resume_routes.ts | 2 ++ .../src/services/resume_service.ts | 3 ++- nextstep-frontend/src/pages/MainDashboard.tsx | 19 ++++++++++++++--- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 8adbf77..16da0ee 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -108,7 +108,7 @@ const parseResume = async (req: CustomRequest, res: Response) => { const resumeFilename = req.body.resumefileName; const parsed = await parseResumeFields(getResumeBuffer(req.body.resumefileName), resumeFilename); - const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename); + const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename, req.body.originfilename); return res.status(200).json(parsed); } catch (err: any) { @@ -117,6 +117,23 @@ const parseResume = async (req: CustomRequest, res: Response) => { } }; +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; @@ -133,4 +150,4 @@ const getResumeData = async (req: CustomRequest, res: Response) => { export default { parseResume, getResumeScore, getStreamResumeScore, getTemplates, - generateResume, getResumeData }; \ No newline at end of file + generateResume, getResumeData, getResume }; \ No newline at end of file diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts index 4a3499a..5abc7d8 100644 --- a/nextstep-backend/src/models/resume_model.ts +++ b/nextstep-backend/src/models/resume_model.ts @@ -7,11 +7,11 @@ const ResumeSchema = new Schema({ 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 } - }, required: false }, diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index 9bb93bd..73b27f6 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -21,5 +21,7 @@ router.post('/parseResume', upload.single('resume'), (req: Request, res: Respon // 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)) + export default router; \ 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 1a22f8b..674a44c 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -343,7 +343,7 @@ const getLatestResumeByUser = async (ownerId: string): Promise => { }; -const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resumeRawLink: string): Promise => { +const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resumeRawLink: string, filename: string): Promise => { const lastVersion = await getLatestResumeByUser(ownerId); const newVersion = lastVersion + 1; @@ -352,6 +352,7 @@ const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resum version: newVersion, rawContentLink: resumeRawLink, parsedData: { + fileName: filename, aboutMe: parsedData.aboutMe, skills: parsedData.skills, roleMatch: parsedData.roleMatch, diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 2aa9f2d..2a6811c 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -57,7 +57,7 @@ const MainDashboard: React.FC = () => { const [useOAuth, setUseOAuth] = useState(true); const [showAuthOptions, setShowAuthOptions] = useState(false); - // AI-resume state + // resume state const [parsing, setParsing] = useState(false); const [resumeExperience, setResumeExperience] = useState([]); const [roleMatch, setRoleMatch] = useState(''); @@ -101,6 +101,19 @@ const MainDashboard: React.FC = () => { } }, []); + // Fetch resume data on mount + useEffect(() => { + const fetchResumeData = async () => { + try { + const response = await api.get('/resume'); + setResumeFileName(response.data.parsedData.fileName || ''); + } catch (err) { + console.error('Failed to fetch resume data:', err); + } + }; + fetchResumeData(); + }, []); + const mergeRepoLanguages = async (fetchedRepos: typeof repos) => { const langSet = new Set(skills); for (const repo of fetchedRepos) { @@ -158,11 +171,11 @@ const MainDashboard: React.FC = () => { const form = new FormData(); form.append('file', file); try { - const uplaodedResume = await uploadResume(form); + const uploadedResume = await uploadResume(form); const res = await api.post('/resume/parseResume', { - resumefileName: uplaodedResume, + resumefileName: uploadedResume, originfilename: file.name, }, { headers: { 'Content-Type': 'multipart/form-data' }, }); From da0c68d54bc24b1cff3bfdd22f770781a2979457 Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 7 Jun 2025 20:29:53 +0300 Subject: [PATCH 06/23] added changes to support resume page integration with server --- .../src/controllers/resume_controller.ts | 41 ++++++++++++++-- nextstep-backend/src/models/resume_model.ts | 5 +- nextstep-backend/src/routes/resume_routes.ts | 2 +- .../src/services/resume_service.ts | 31 ++++++++++-- nextstep-backend/src/types/resume_types.ts | 4 ++ nextstep-frontend/src/pages/MainDashboard.tsx | 2 + nextstep-frontend/src/pages/Resume.tsx | 49 +++++++++++++++---- 7 files changed, 115 insertions(+), 19 deletions(-) diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 16da0ee..1d2e196 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -4,12 +4,19 @@ import fs from 'fs'; import path from 'path'; import { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields, - saveParsedResume, getResumeByOwner } from '../services/resume_service'; + saveParsedResume, getResumeByOwner, updateResume } from '../services/resume_service'; import multer from 'multer'; -import {getResumeBuffer, resumeExists, uploadResume} from '../services/resources_service'; +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; @@ -20,7 +27,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) { @@ -31,7 +45,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); @@ -41,6 +55,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'); @@ -52,10 +78,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`); } ); @@ -63,6 +91,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); diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts index 5abc7d8..75a4250 100644 --- a/nextstep-backend/src/models/resume_model.ts +++ b/nextstep-backend/src/models/resume_model.ts @@ -11,7 +11,10 @@ const ResumeSchema = new Schema({ aboutMe: { type: String, required: false }, skills: { type: [String], required: false }, roleMatch: { type: String, required: false }, - experience: { 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 }, diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index 73b27f6..fdfd9f6 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -10,7 +10,7 @@ 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.get('/templates', Resume.getTemplates); diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index 674a44c..51de80f 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -58,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() == '') { @@ -88,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); @@ -364,6 +364,31 @@ const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resum 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 { @@ -395,4 +420,4 @@ const getResumeByOwner = async (ownerId: string, version?: number) => { export { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields, - saveParsedResume, getResumeByOwner }; \ No newline at end of file + saveParsedResume, getResumeByOwner, updateResume, }; \ No newline at end of file diff --git a/nextstep-backend/src/types/resume_types.ts b/nextstep-backend/src/types/resume_types.ts index 75791b3..4dd5248 100644 --- a/nextstep-backend/src/types/resume_types.ts +++ b/nextstep-backend/src/types/resume_types.ts @@ -7,6 +7,10 @@ export interface ParsedResume { roleMatch: string; experience: string[]; education?: string[]; + jobDescription?: string; + feedback?: string; + score?: number; + fileName?: string; } export interface ResumeDocument extends Document { diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 2a6811c..b8bd87d 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -107,6 +107,8 @@ const MainDashboard: React.FC = () => { try { const response = await api.get('/resume'); setResumeFileName(response.data.parsedData.fileName || ''); + setResumeExperience(response.data.parsedData.experience || []); + setRoleMatch(response.data.parsedData.roleMatch || ''); } catch (err) { console.error('Failed to fetch resume data:', err); } diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx index 9ff670e..6b21eba 100644 --- a/nextstep-frontend/src/pages/Resume.tsx +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -181,6 +181,7 @@ const GeneratedWordPreview: React.FC<{ base64Content: string }> = ({ base64Conte const Resume: React.FC = () => { const [activeStep, setActiveStep] = useState(0); const [file, setFile] = useState(null); + const [fileName, setFileName] = useState(''); const [jobDescription, setJobDescription] = useState(''); const [feedback, setFeedback] = useState(''); const [score, setScore] = useState(null); @@ -216,6 +217,26 @@ const Resume: React.FC = () => { fetchTemplates(); }, []); + // 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); + } + + } catch (err) { + console.error('Failed to fetch resume data:', err); + } + }; + fetchResumeData(); + }, []); + const uploadResume = async (formData: FormData) => { const response = await api.post('/resource/resume', formData, { headers: { @@ -228,6 +249,7 @@ const Resume: React.FC = () => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { setFile(event.target.files[0]); + setFileName(event.target.files[0].name); setError(''); } }; @@ -237,7 +259,7 @@ const Resume: React.FC = () => { }; const handleSubmit = async () => { - if (!file) { + if (!fileName) { setError('Please select a file'); return; } @@ -248,11 +270,17 @@ const Resume: React.FC = () => { setError(''); try { - const formData = new FormData(); - formData.append('file', file); - - const filename = await uploadResume(formData); + var filename = ''; + if (file) { + const formData = new FormData(); + formData.append('file', file); + filename = await uploadResume(formData); + } + else { + const resume = await api.get('/resume'); + filename = resume.data.rawContentLink.split('/').pop() || ''; + } const token = localStorage.getItem(config.localStorageKeys.userAuth) ? JSON.parse(localStorage.getItem(config.localStorageKeys.userAuth)!).accessToken : ''; @@ -269,7 +297,8 @@ const Resume: React.FC = () => { setScore(data.score); eventSource.close(); setLoading(false); - setActiveStep(1); // Move to next step after scoring + data.fullText && setFeedback(data.fullText); + // setActiveStep(1); // Move to next step after scoring } else if (data.chunk) { setFeedback(prev => prev + data.chunk); } @@ -462,8 +491,8 @@ const Resume: React.FC = () => { /> - {file ? ( - {file.name} + {fileName ? ( + {fileName} ) : ( Click to upload your resume )} @@ -479,7 +508,7 @@ const Resume: React.FC = () => { - )} - - 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} - - )} - - {/* Experience */} - {resumeExperience.length > 0 && ( - - - - - Experience + + {profileCompletion}% - - {resumeExperience.map((exp, i) => ( - {exp} - ))} - - - )} - - - - {/* Right Column */} - - - - - Connect Accounts - - - {showAuthOptions ? ( - - - Method - - - - - - ) : ( - - - - - )} - - {repos.length > 0 && ( - - - Repositories: - - - {repos.map(repo => ( - - ))} - - - )} - + + + + + + + + Target Role + + + What's your dream job? + + + + setSelectedRole(val)} + renderInput={(params) => ( + + )} + /> + + + + + + {/* Skills */} + + + + + + + + + + + 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), + }, + }} + > + + + + + + + + + + {/* AI Insights Row */} + {(roleMatch || resumeExperience.length > 0) && ( + + {/* Suggested Role Match */} + {roleMatch && ( + + + + + + + + + + + AI Suggestion + + + Based on your profile + + + + + "{roleMatch}" + + + + + + )} - {/* Jobs Section */} - + {/* 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 + + + + + {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 */} + + + + + - - - ); -}; + + + ) +} -export default MainDashboard; +export default MainDashboard 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 +}) From f7a84e9e7eedb7669ec3046d4efaf3c9cab05e9f Mon Sep 17 00:00:00 2001 From: lina elman Date: Thu, 12 Jun 2025 19:58:03 +0300 Subject: [PATCH 08/23] added fixes to dashboard --- .../src/components/LinkedinJobs.tsx | 21 ++- nextstep-frontend/src/pages/MainDashboard.tsx | 159 +++++++++--------- 2 files changed, 98 insertions(+), 82 deletions(-) diff --git a/nextstep-frontend/src/components/LinkedinJobs.tsx b/nextstep-frontend/src/components/LinkedinJobs.tsx index 8592af7..671062c 100644 --- a/nextstep-frontend/src/components/LinkedinJobs.tsx +++ b/nextstep-frontend/src/components/LinkedinJobs.tsx @@ -1,5 +1,5 @@ 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 { Box, Typography, Button, Grid, CircularProgress, IconButton, TextField, MenuItem, Select, FormControl, InputLabel, Dialog, DialogTitle, DialogContent, DialogActions, Chip, Stack, CardContent, Card, useTheme } from '@mui/material'; import { ExpandLess, LinkedIn, Settings } from '@mui/icons-material'; interface Job { @@ -46,6 +46,7 @@ const LinkedinJobs: React.FC = ({ 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); @@ -75,7 +76,20 @@ const LinkedinJobs: React.FC = ({ }; return ( - + + @@ -309,7 +323,8 @@ const LinkedinJobs: React.FC = ({ )} - + + ); }; diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 62d3d0c..f51852b 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -289,7 +289,7 @@ const MainDashboard: React.FC = () => { mb: 1, }} > - Welcome to NextStep + NextStep Your personalized career development dashboard @@ -351,6 +351,7 @@ const MainDashboard: React.FC = () => { transform: "translateY(-4px)", boxShadow: `0 12px 48px ${alpha(theme.palette.common.black, 0.15)}`, }, + // flexDirection: "column", }} > @@ -447,83 +448,9 @@ const MainDashboard: React.FC = () => { {/* Role and Skills Row */} - - {/* Desired Role */} - - - - - - - - - - - Target Role - - - What's your dream job? - - - - setSelectedRole(val)} - renderInput={(params) => ( - - )} - /> - - - - - + {/* Skills */} - + { + + {/* Desired Role */} + + + + + + + + + + + Target Role + + + What's your dream job? + + + + setSelectedRole(val)} + renderInput={(params) => ( + + )} + /> + + + + {/* AI Insights Row */} {(roleMatch || resumeExperience.length > 0) && ( - + {/* Suggested Role Match */} {roleMatch && ( - + Date: Fri, 13 Jun 2025 17:07:21 +0300 Subject: [PATCH 09/23] changed linkedin jobs --- .../src/components/LinkedinJobs.tsx | 1112 +++++++++++++---- nextstep-frontend/src/pages/MainDashboard.tsx | 5 - 2 files changed, 841 insertions(+), 276 deletions(-) diff --git a/nextstep-frontend/src/components/LinkedinJobs.tsx b/nextstep-frontend/src/components/LinkedinJobs.tsx index 671062c..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, CardContent, Card, useTheme } 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,292 +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/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index f51852b..bdb52c9 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -30,7 +30,6 @@ import { Person as PersonIcon, Work as WorkIcon, Build as BuildIcon, - DocumentScannerTwoTone, LightbulbSharp, Grading, Add as AddIcon, @@ -38,11 +37,7 @@ import { Star, Code, Business, - UploadFileRounded, - UploadRounded, - CheckCircleOutline, CheckCircle, - ArticleOutlined, InsertDriveFile, } from "@mui/icons-material" import { connectToGitHub, initiateGitHubOAuth, fetchRepoLanguages, handleGitHubOAuth } from "../handlers/githubAuth" From 38f0ec7e25cd81ff7e7142816c27e25215db5327 Mon Sep 17 00:00:00 2001 From: lina elman Date: Fri, 13 Jun 2025 18:55:45 +0300 Subject: [PATCH 10/23] changed design --- nextstep-frontend/src/pages/Feed.tsx | 975 +++++++++---- nextstep-frontend/src/pages/MainDashboard.tsx | 6 +- nextstep-frontend/src/pages/PostDetails.tsx | 1249 ++++++++++++----- 3 files changed, 1587 insertions(+), 643 deletions(-) diff --git a/nextstep-frontend/src/pages/Feed.tsx b/nextstep-frontend/src/pages/Feed.tsx index b3d1aea..af8a287 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, + Image as ImageIcon, + Article, + Send, +} 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 [savedPosts, setSavedPosts] = useState([]) + + const auth = getUserAuth() const handleCreatePost = () => { - setShowNewPostModal(true); - }; + setShowNewPostModal(true) + } + + const handleShareResume = () => { + // Placeholder for resume sharing functionality + console.log("Share resume clicked") + } const handleDeletePost = async () => { if (postIdToDelete) { @@ -58,326 +81,724 @@ 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); - }; - - // useEffect(() => { - // const fetchResumeData = async () => { - // try { - // const resumeData = await api.get('/resume'); - // const resume = await api.get(`/resource/resume/${resumeData.data.rawContentLink}`); - // console.log("a"); - // } catch (err) { - // console.error('Failed to fetch resume data:', err); - // } - // }; - // fetchResumeData(); - // }, []); + setOpenDialog(false) + setPostIdToDelete(null) + } const fetchProfileImage = async (imageFilename: string | null) => { try { if (!imageFilename) { - return defaultProfileImage; + return defaultProfileImage } 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 formatContent = (content: string) => { + // Strip HTML tags for preview + const strippedContent = content.replace(/<[^>]*>?/gm, "") + return strippedContent.length > 150 ? strippedContent.substring(0, 150) + "..." : strippedContent + } + + 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 bdb52c9..2e3e189 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -277,10 +277,14 @@ const MainDashboard: React.FC = () => { variant="h3" sx={{ fontWeight: 800, - background: `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`, + display: "inline", + background: theme.palette.mode === "dark" + ? "linear-gradient(45deg, #60a5fa 30%, #34d399 90%)" + : "linear-gradient(45deg, #3b82f6 30%, #10b981 90%)", backgroundClip: "text", WebkitBackgroundClip: "text", color: "transparent", + WebkitTextFillColor: "transparent", mb: 1, }} > 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 From a2deca9fbfbf471b154e8d2f6bb4b927b6be73be Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 14 Jun 2025 14:28:19 +0300 Subject: [PATCH 11/23] added files for resume in resources --- nextstep-backend/src/app.ts | 1 + nextstep-backend/src/config/config.ts | 2 + .../src/controllers/resources_controller.ts | 33 +- .../src/routes/resources_routes.ts | 2 + .../src/services/resources_service.ts | 59 +- nextstep-frontend/src/components/NewPost.tsx | 542 +++++++++++++----- nextstep-frontend/src/pages/Feed.tsx | 18 +- 7 files changed, 505 insertions(+), 152 deletions(-) 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 d985d65..642ae8f 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 1ef354a..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,6 +65,22 @@ 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); @@ -85,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/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/services/resources_service.ts b/nextstep-backend/src/services/resources_service.ts index 5907a39..4753072 100644 --- a/nextstep-backend/src/services/resources_service.ts +++ b/nextstep-backend/src/services/resources_service.ts @@ -9,6 +9,43 @@ 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 allowedTypes = /pdf|docx|docs/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (extname && mimetype) { + return cb(null, true); + } else { + return cb(new TypeError(`Invalid file type. Only images are allowed: ${allowedTypes}`)); + } + } + }); +}; + const createImagesStorage = () => { // Ensure the directory exists const imagesResourcesDir = config.resources.imagesDirectoryPath(); @@ -103,6 +140,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) => { @@ -151,4 +208,4 @@ const resumeExists = (filename: string): boolean => { }; -export { uploadImage, uploadResume, getResumeBuffer, resumeExists }; \ No newline at end of file +export { uploadImage, uploadResume, getResumeBuffer, resumeExists, uploadFile }; \ No newline at end of file diff --git a/nextstep-frontend/src/components/NewPost.tsx b/nextstep-frontend/src/components/NewPost.tsx index 15a0dbe..63eaa8a 100644 --- a/nextstep-frontend/src/components/NewPost.tsx +++ b/nextstep-frontend/src/components/NewPost.tsx @@ -1,165 +1,415 @@ -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 [resumeData, setResumeData] = useState(null) + const [resumeFile, setResumeFile] = useState(null) + const [isLoadingResume, setIsLoadingResume] = useState(false) + const fileInputRef = useRef(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.json()) + .then(({ url, name }) => { + const html = `${name || 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) + setResumeData(null) + setResumeFile(null) + } + }, [open, withResume]) + + // 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 { + setIsLoadingResume(true) + 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", + }) + + setResumeData(resumeResponse.data) + 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.") + } finally { + setIsLoadingResume(false) + } + } + + const handleFroalaImageUpload = async (file: File) => { + try { + const formData = new FormData() + formData.append("file", file) + + const response = await api.post("/resource/image", formData, { + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${auth.accessToken}`, + }, + }) + + return JSON.stringify({ link: response.data.url }) + } catch (error) { + console.error("Error uploading image:", error) + throw error + } + } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleFroalaFileUpload = async (file: File) => { + try { + const formData = new FormData() + formData.append("file", file) + + const response = await api.post("/resource/file", formData, { + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${auth.accessToken}`, + }, + }) + + return JSON.stringify({ link: response.data.url, name: file.name }) + } catch (error) { + console.error("Error uploading file:", error) + throw error + } + } + const insertResumeIntoEditor = async () => { + if (!resumeFile || !editorRef.current?.editor) return; + try { - // Submit the post with the content (images are already uploaded and URLs are in place) - await api.post(`/post`, { - title, - content, + 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 && content && ( + + )} + + + + + + + + + ) +} + +export default NewPostModal diff --git a/nextstep-frontend/src/pages/Feed.tsx b/nextstep-frontend/src/pages/Feed.tsx index af8a287..e1271ec 100644 --- a/nextstep-frontend/src/pages/Feed.tsx +++ b/nextstep-frontend/src/pages/Feed.tsx @@ -35,9 +35,9 @@ import { Delete, Bookmark, BookmarkBorder, - Image as ImageIcon, Article, Send, + Description, } from "@mui/icons-material" import type { Post } from "../models/Post.tsx" import api from "../serverApi.ts" @@ -61,6 +61,7 @@ const Feed: React.FC = () => { 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() @@ -70,8 +71,7 @@ const Feed: React.FC = () => { } const handleShareResume = () => { - // Placeholder for resume sharing functionality - console.log("Share resume clicked") + setShowResumePostModal(true) } const handleDeletePost = async () => { @@ -380,7 +380,7 @@ const Feed: React.FC = () => { + {(showAllSkills ? skills : skills.slice(0, SKILL_DISPLAY_LIMIT)).map((skill, index) => ( { 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(''); From 97110927041a04ef172bc978fc6373607ade8f72 Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 5 Jul 2025 09:48:08 +0300 Subject: [PATCH 13/23] fixed files uploading --- .../src/services/resources_service.ts | 16 ++++++++++------ nextstep-backend/src/services/resume_service.ts | 5 ++--- nextstep-frontend/src/components/NewPost.tsx | 7 ++++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/nextstep-backend/src/services/resources_service.ts b/nextstep-backend/src/services/resources_service.ts index 4753072..b4030ac 100644 --- a/nextstep-backend/src/services/resources_service.ts +++ b/nextstep-backend/src/services/resources_service.ts @@ -33,14 +33,18 @@ const createFilesStorage = () => { fileSize: config.resources.fileMaxSize() }, fileFilter: (req, file, cb) => { - const allowedTypes = /pdf|docx|docs/; - const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); - const mimetype = allowedTypes.test(file.mimetype); - - if (extname && mimetype) { + 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. Only images are allowed: ${allowedTypes}`)); + return cb(new TypeError(`Invalid file type (${mime}). Allowed: ${allowedExts.join(', ')}`)); } } }); diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index 51de80f..68680e5 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -406,9 +406,8 @@ const getResumeByOwner = async (ownerId: string, version?: number) => { .exec(); if (!resume) { - throw new Error(version !== undefined - ? `Resume version ${version} not found for user ${ownerId}` - : `No resume found for user ${ownerId}`); + version !== undefined && throw new Error(`Resume version ${version} not found for user ${ownerId}`); + return; } return resume; diff --git a/nextstep-frontend/src/components/NewPost.tsx b/nextstep-frontend/src/components/NewPost.tsx index 0c3ffed..31c4632 100644 --- a/nextstep-frontend/src/components/NewPost.tsx +++ b/nextstep-frontend/src/components/NewPost.tsx @@ -118,9 +118,10 @@ const NewPostModal: React.FC = ({ open, onClose, onPostCreate }, body: formData, }) - .then(res => res.json()) - .then(({ url, name }) => { - const html = `${name || file.name}`; + .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 => { From 2818ee5d7ae2ce8083602c67ddf66e81bc0eff49 Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 5 Jul 2025 09:52:21 +0300 Subject: [PATCH 14/23] fixed profile image fetching --- nextstep-backend/src/services/resume_service.ts | 6 ++++-- nextstep-frontend/src/pages/Feed.tsx | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index 68680e5..c19c990 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -406,8 +406,10 @@ const getResumeByOwner = async (ownerId: string, version?: number) => { .exec(); if (!resume) { - version !== undefined && throw new Error(`Resume version ${version} not found for user ${ownerId}`); - return; + if (version !== undefined) { + throw new Error(`Resume version ${version} not found for user ${ownerId}`); + } + console.log(`No resume found for user ${ownerId}`); } return resume; diff --git a/nextstep-frontend/src/pages/Feed.tsx b/nextstep-frontend/src/pages/Feed.tsx index 16051b1..bfe1bca 100644 --- a/nextstep-frontend/src/pages/Feed.tsx +++ b/nextstep-frontend/src/pages/Feed.tsx @@ -107,6 +107,9 @@ const Feed: React.FC = () => { if (!imageFilename) { return defaultProfileImage } + if (imageFilename.startsWith("https://")) { + return imageFilename + } const response = await api.get(`/resource/image/${imageFilename}`, { responseType: "blob", }) From e800d4f3f44e7c8cc2d9d4faccd88b32d11893f6 Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 5 Jul 2025 10:29:23 +0300 Subject: [PATCH 15/23] added design changes --- nextstep-frontend/src/pages/Quiz.tsx | 1264 ++++++++++++++---------- nextstep-frontend/src/pages/Resume.tsx | 5 +- 2 files changed, 735 insertions(+), 534 deletions(-) diff --git a/nextstep-frontend/src/pages/Quiz.tsx b/nextstep-frontend/src/pages/Quiz.tsx index 3d5553c..2ec142e 100644 --- a/nextstep-frontend/src/pages/Quiz.tsx +++ b/nextstep-frontend/src/pages/Quiz.tsx @@ -1,5 +1,8 @@ -import React, { useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +"use client" + +import type React from "react" +import { useState } from "react" +import { useSearchParams } from "react-router-dom" import { Container, Box, @@ -10,7 +13,6 @@ import { IconButton, Tooltip, Paper, - Slider, Stack, Chip, Divider, @@ -19,139 +21,151 @@ import { FormGroup, FormControlLabel, Checkbox, -} from '@mui/material'; + useTheme, + alpha, + Card, + CardContent, + LinearProgress, + Fade, + Zoom, +} from "@mui/material" import { Visibility, VisibilityOff, LightbulbOutlined as LightbulbOutlinedIcon, WorkOutline as WorkOutlineIcon, InfoOutlined as InfoOutlinedIcon, - ForumOutlined as ForumOutlinedIcon, BusinessOutlined as BusinessOutlinedIcon, - LocalOfferOutlined as LocalOfferOutlinedIcon, -} from '@mui/icons-material'; -import SchoolIcon from '@mui/icons-material/School'; // Import graduation hat icon -import api from '../serverApi'; -import { config } from '../config'; + Quiz as QuizIcon, + Psychology, + EmojiEvents, + AutoAwesome, + Refresh, +} from "@mui/icons-material" +import api from "../serverApi" +import { config } from "../config" // Define interfaces for the API response schemas interface QuizGenerationResponse { - _id: string; - title: string; - tags: string[]; - content: string; - job_role: string; - company_name_en: string; - company_name_he: string; - process_details: string; - question_list: string[]; - answer_list: string[]; - keywords: string[]; - interviewer_mindset: string; - specialty_tags: string[]; + _id: string + title: string + tags: string[] + content: string + job_role: string + company_name_en: string + company_name_he: string + process_details: string + question_list: string[] + answer_list: string[] + keywords: string[] + interviewer_mindset: string + specialty_tags: string[] } interface UserAnsweredQuiz { - _id: string; - title: string; - tags: string[]; - content: string; - job_role: string; - company_name_en: string; - company_name_he: string; - process_details: string; - question_list: string[]; - answer_list: string[]; - user_answer_list: string[]; - keywords: string[]; - interviewer_mindset: string; - specialty_tags: string[]; + _id: string + title: string + tags: string[] + content: string + job_role: string + company_name_en: string + company_name_he: string + process_details: string + question_list: string[] + answer_list: string[] + user_answer_list: string[] + keywords: string[] + interviewer_mindset: string + specialty_tags: string[] } interface GradedAnswer { - question: string; - user_answer: string; - grade: number; - tip: string; + question: string + user_answer: string + grade: number + tip: string } interface QuizGradingResponse { - graded_answers: GradedAnswer[]; - final_quiz_grade: number; - final_summary_tip: string; + graded_answers: GradedAnswer[] + final_quiz_grade: number + final_summary_tip: string } // Internal state structure for the quiz, combining generated and graded data interface QuizStateQuestion { - originalQuestion: string; - userAnswer: string; - correctAnswer?: string; - grade?: number; - tip?: string; + originalQuestion: string + userAnswer: string + correctAnswer?: string + grade?: number + tip?: string } interface QuizState { - _id: string; - subject: string; - questions: QuizStateQuestion[]; - finalGrade?: number; - finalTip?: string; + _id: string + subject: string + questions: QuizStateQuestion[] + finalGrade?: number + finalTip?: string // --- Additional fields from QuizGenerationResponse for display --- - title?: string; - tags?: string[]; - content?: string; - jobRole?: string; - companyNameEn?: string; - processDetails?: string; - keywords?: string[]; - interviewerMindset?: string; - answer_list?: string[]; // Store the original answer list for display after grading - specialty_tags?: string[]; // Add specialty tags to the state + title?: string + tags?: string[] + content?: string + jobRole?: string + companyNameEn?: string + processDetails?: string + keywords?: string[] + interviewerMindset?: string + answer_list?: string[] + specialty_tags?: string[] } const Quiz: React.FC = () => { - const [searchParams] = useSearchParams(); - const [subject, setSubject] = useState(searchParams.get('subject') || ''); + const theme = useTheme() + const [searchParams] = useSearchParams() + const [subject, setSubject] = useState(searchParams.get("subject") || "") const [selectedSpecialties, setSelectedSpecialties] = useState<{ - code: boolean; - design: boolean; - technologies: boolean; + code: boolean + design: boolean + technologies: boolean }>({ code: false, design: false, technologies: false, - }); - const [quiz, setQuiz] = useState(null); - const [loading, setLoading] = useState(false); - const [showAnswer, setShowAnswer] = useState<{ [key: number]: boolean }>({}); - const [quizSubmitted, setQuizSubmitted] = useState(false); + }) + const [quiz, setQuiz] = useState(null) + const [loading, setLoading] = useState(false) + const [showAnswer, setShowAnswer] = useState<{ [key: number]: boolean }>({}) + const [quizSubmitted, setQuizSubmitted] = useState(false) const handleGenerateQuiz = async () => { - if (!subject.trim()) return; - setLoading(true); - setQuiz(null); - setQuizSubmitted(false); - setShowAnswer({}); + if (!subject.trim()) return + setLoading(true) + setQuiz(null) + setQuizSubmitted(false) + setShowAnswer({}) // Build the full subject with specialties - let fullSubject = subject; - if (selectedSpecialties.code) fullSubject += ' SPECIALTY_CODE'; - if (selectedSpecialties.design) fullSubject += ' SPECIALTY_DESIGN'; - if (selectedSpecialties.technologies) fullSubject += ' SPECIALTY_TECHNOLOGIES'; + let fullSubject = subject + if (selectedSpecialties.code) fullSubject += " SPECIALTY_CODE" + if (selectedSpecialties.design) fullSubject += " SPECIALTY_DESIGN" + if (selectedSpecialties.technologies) fullSubject += " SPECIALTY_TECHNOLOGIES" try { - const response = await api.post(`${config.app.backend_url()}/quiz/generate`, { subject: fullSubject }); + const response = await api.post(`${config.app.backend_url()}/quiz/generate`, { + subject: fullSubject, + }) // Validate the response data if (!response.data || !response.data.question_list || !response.data.answer_list) { - throw new Error('Invalid quiz data received from server'); + throw new Error("Invalid quiz data received from server") } const generatedQuestions: QuizStateQuestion[] = response.data.question_list.map((q: string, idx: number) => ({ originalQuestion: q, - userAnswer: '', - correctAnswer: response.data.answer_list[idx], // Populate correct answer immediately - })); + userAnswer: "", + correctAnswer: response.data.answer_list[idx], + })) setQuiz({ _id: response.data._id, @@ -167,507 +181,693 @@ const Quiz: React.FC = () => { interviewerMindset: response.data.interviewer_mindset, answer_list: response.data.answer_list, specialty_tags: response.data.specialty_tags, - }); - + }) } catch (error: any) { - console.error('Error generating quiz:', error); - let errorMessage = 'Failed to generate quiz. '; - + console.error("Error generating quiz:", error) + let errorMessage = "Failed to generate quiz. " + if (error.response?.data?.message) { - // Server returned an error message - errorMessage += error.response.data.message; + errorMessage += error.response.data.message } else if (error.message) { - // Other error with message - errorMessage += error.message; + errorMessage += error.message } else { - // Generic error - errorMessage += 'Please try again.'; + errorMessage += "Please try again." } - // Show error in a more user-friendly way - alert(errorMessage); - - // Reset loading state - setLoading(false); - return; + alert(errorMessage) + setLoading(false) + return } finally { - setLoading(false); + setLoading(false) } - }; + } const handleUserAnswerChange = (index: number, answer: string) => { if (quiz) { - const updatedQuestions = [...quiz.questions]; - updatedQuestions[index].userAnswer = answer; - setQuiz({ ...quiz, questions: updatedQuestions }); + const updatedQuestions = [...quiz.questions] + updatedQuestions[index].userAnswer = answer + setQuiz({ ...quiz, questions: updatedQuestions }) } - }; + } const handleToggleAnswerVisibility = (index: number) => { - setShowAnswer(prev => ({ + setShowAnswer((prev) => ({ ...prev, [index]: !prev[index], - })); - }; + })) + } const handleSubmitQuiz = async () => { - if (!quiz || quizSubmitted) return; - setLoading(true); + if (!quiz || quizSubmitted) return + setLoading(true) const answeredQuizData: UserAnsweredQuiz = { _id: quiz._id, - title: quiz.title || '', + title: quiz.title || "", tags: quiz.tags || [], - content: quiz.content || '', - job_role: quiz.jobRole || '', - company_name_en: quiz.companyNameEn || '', - company_name_he: '', - process_details: quiz.processDetails || '', - question_list: quiz.questions.map(q => q.originalQuestion), + content: quiz.content || "", + job_role: quiz.jobRole || "", + company_name_en: quiz.companyNameEn || "", + company_name_he: "", + process_details: quiz.processDetails || "", + question_list: quiz.questions.map((q) => q.originalQuestion), answer_list: quiz.answer_list || [], - user_answer_list: quiz.questions.map(q => q.userAnswer), + user_answer_list: quiz.questions.map((q) => q.userAnswer), keywords: quiz.keywords || [], - interviewer_mindset: quiz.interviewerMindset || '', + interviewer_mindset: quiz.interviewerMindset || "", specialty_tags: quiz.specialty_tags || [], - }; + } try { - const response = await api.post(`${config.app.backend_url()}/quiz/grade`, answeredQuizData); + const response = await api.post(`${config.app.backend_url()}/quiz/grade`, answeredQuizData) - const gradedQuizData = response.data; + const gradedQuizData = response.data const updatedQuestions = quiz.questions.map((q, _) => { - const gradedAnswer = gradedQuizData.graded_answers.find(ga => ga.question === q.originalQuestion); + const gradedAnswer = gradedQuizData.graded_answers.find((ga) => ga.question === q.originalQuestion) return { ...q, grade: gradedAnswer?.grade, tip: gradedAnswer?.tip, - // correctAnswer is already present from generation - }; - }); + } + }) setQuiz({ ...quiz, questions: updatedQuestions, finalGrade: gradedQuizData.final_quiz_grade, finalTip: gradedQuizData.final_summary_tip, - }); - setQuizSubmitted(true); + }) + setQuizSubmitted(true) // After submission, automatically show all correct answers and grades - const initialShowAnswer: { [key: number]: boolean } = {}; + const initialShowAnswer: { [key: number]: boolean } = {} updatedQuestions.forEach((_, index) => { - initialShowAnswer[index] = true; - }); - setShowAnswer(initialShowAnswer); - + initialShowAnswer[index] = true + }) + setShowAnswer(initialShowAnswer) } catch (error) { - console.error('Error submitting quiz:', error); - alert('Failed to submit quiz for grading. Please try again.'); + console.error("Error submitting quiz:", error) + alert("Failed to submit quiz for grading. Please try again.") } finally { - setLoading(false); + setLoading(false) } - }; + } const handleEditSubject = (newSubject: string) => { - setSubject(newSubject); - setQuiz(null); // Reset the quiz to allow generating a new one with the updated subject - }; + setSubject(newSubject) + setQuiz(null) + } + + const getGradeColor = (grade: number) => { + if (grade >= 90) return theme.palette.success.main + if (grade >= 70) return theme.palette.warning.main + return theme.palette.error.main + } + + const getGradeEmoji = (grade: number) => { + if (grade >= 90) return "🏆" + if (grade >= 80) return "🎉" + if (grade >= 70) return "👍" + if (grade >= 60) return "📚" + return "💪" + } return ( - - - Quiz Generator &{' '} - - - Grader - - - - {/* Subject Input */} - {!quiz && ( - - setSubject(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleGenerateQuiz()} - sx={{ mb: 2 }} - /> - - {/* Specialty Selection */} - - Select Specialties (Optional): - - - setSelectedSpecialties(prev => ({ ...prev, code: e.target.checked }))} - /> - } - label="Code" - /> - setSelectedSpecialties(prev => ({ ...prev, design: e.target.checked }))} - /> - } - label="Design" - /> - setSelectedSpecialties(prev => ({ ...prev, technologies: e.target.checked }))} - /> - } - label="Technologies" - /> - - - - - )} - - {/* Generated Quiz Display */} - {quiz && ( - - - Quiz on: - handleEditSubject(e.target.value)} - variant="outlined" - size="small" - sx={{ ml: 2, width: '50%' }} - /> - - - {/* --- Enhanced Display of Quiz Metadata --- */} - - - {quiz.title && ( - - - - Quiz Title: {quiz.title} - - - )} - - {quiz.jobRole && ( - - - - Job Role: {quiz.jobRole} - - - )} - - {quiz.companyNameEn && ( - - - - Company: {quiz.companyNameEn} - - - )} - - {quiz.tags && quiz.tags.length > 0 && ( - - - - Tags: + + + + + {/* Header Section */} + + + + + Quiz Generator + + + + + Generate personalized quizzes with AI-powered grading and detailed feedback + + + + + + {/* Subject Input Section */} + {!quiz && ( + + + + + + + Create Your Quiz - - {quiz.tags.map((tag, i) => ( - - ))} - - - )} - - {quiz.specialty_tags && quiz.specialty_tags.length > 0 && ( - - - - Specialties: + + + setSubject(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGenerateQuiz()} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + fontSize: "1.1rem", + }, + }} + /> + + + + Specialization Focus (Optional): - - {quiz.specialty_tags.map((specialty, i) => ( - + setSelectedSpecialties((prev) => ({ ...prev, code: e.target.checked }))} + sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }} + /> + } + label={💻 Code} + /> + setSelectedSpecialties((prev) => ({ ...prev, design: e.target.checked }))} + sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }} + /> + } + label={🎨 Design} + /> + + setSelectedSpecialties((prev) => ({ ...prev, technologies: e.target.checked })) } - }} - /> - ))} - - - )} - - {quiz.keywords && quiz.keywords.length > 0 && ( - - - - Keywords: - - - {quiz.keywords.map((keyword, i) => ( - - ))} - - - )} - - {quiz.processDetails && ( - - - - Process Details: - - - {quiz.processDetails} - - - )} - - {quiz.content && ( - - - - Context/Content: - - - {quiz.content} - - - )} - - {quiz.interviewerMindset && ( - - - - Interviewer Mindset: - - - "{quiz.interviewerMindset}" + sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }} + /> + } + label={⚡ Technologies} + /> + + + + + + + + )} + + {/* Generated Quiz Display */} + {quiz && ( + + + + {/* Quiz Header */} + + + Quiz: {subject} - - )} - - - - {/* --- End Enhanced Display of Quiz Metadata --- */} - - - Your answers may get better grades for broad, in-depth explanations. You can answer in any language you want! - - {quiz.questions.map((q, index) => ( - - - {/* Circled Numbering (Option 2) */} - handleEditSubject(e.target.value)} + variant="outlined" + size="small" + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + }, + }} + /> + + + {/* Quiz Metadata */} + - {index + 1} - - {/* Question Text */} - - {q.originalQuestion} - - {/* Blinking Eye Icon (now always visible if answer exists) */} - {q.correctAnswer && ( - - handleToggleAnswerVisibility(index)} size="small" sx={{ flexShrink: 0, ml: 1 }}> - {showAnswer[index] ? : } - - - )} - - handleUserAnswerChange(index, e.target.value)} - sx={{ mb: 2 }} - disabled={quizSubmitted} - /> - - {/* Grade and Tip are still shown only after submission */} - {quizSubmitted && ( - <> - - - Your Grade: - - - + {quiz.title && ( + + + + + {quiz.title} + + + + )} + + {(quiz.jobRole || quiz.companyNameEn) && ( + + + {quiz.jobRole && ( + + + + + Role: {quiz.jobRole} + + + + )} + {quiz.companyNameEn && ( + + + + + Company: {quiz.companyNameEn} + + + + )} + + + )} + + {quiz.tags && quiz.tags.length > 0 && ( + + + 📌 Tags: + + + {quiz.tags.map((tag, i) => ( + + ))} + + + )} + + {quiz.specialty_tags && quiz.specialty_tags.length > 0 && ( + + + ⚡ Specialties: + + + {quiz.specialty_tags.map((specialty, i) => ( + + ))} + + + )} + + + + + + {/* Instructions */} + + + 💡 Tip: Provide detailed, comprehensive answers for better grades. You can answer in any language! + + + + {/* Questions */} + {quiz.questions.map((q, index) => ( + + + + + + {index + 1} + + + {q.originalQuestion} + + + + {q.correctAnswer && ( + + handleToggleAnswerVisibility(index)} + size="small" + sx={{ + ml: 2, + color: theme.palette.primary.main, + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.1), + }, + }} + > + {showAnswer[index] ? : } + + + )} + + + handleUserAnswerChange(index, e.target.value)} + disabled={quizSubmitted} + sx={{ + mb: 2, + "& .MuiOutlinedInput-root": { + borderRadius: 2, }, - '& .MuiSlider-valueLabel': { - top: -6, - } }} - disabled /> - - - - - Tip: - - - - - {q.tip} - - + + {/* Grade Display */} + {quizSubmitted && q.grade !== undefined && ( + + + + Your Grade: {getGradeEmoji(q.grade)} + + + + + + )} + + {/* Tip Display */} + {quizSubmitted && q.tip && ( + + + + + + 💡 Improvement Tip: + + {q.tip} + + + + )} + + {/* Correct Answer Display */} + {showAnswer[index] && q.correctAnswer && ( + + + ✅ Correct Answer: + + {q.correctAnswer} + + )} + + + ))} + + {/* Submit Button */} + {!quizSubmitted && ( + + - - )} - {/* Correct answer display is now independent of quizSubmitted for showing */} - {showAnswer[index] && q.correctAnswer && ( - theme.palette.mode === 'light' ? '#FFFFFF' : 'background.paper', - borderRadius: 1, - border: '1px solid', - borderColor: 'divider' - }}> - Correct Answer: - {q.correctAnswer} - - )} - - ))} - - {!quizSubmitted && ( - - )} - - {quizSubmitted && quiz.finalGrade !== undefined && ( - theme.palette.mode === 'light' ? '#e8f5e9' : 'rgba(46, 125, 50, 0.15)', - borderRadius: 2, - boxShadow: 2, - textAlign: 'center', - border: '1px solid', - borderColor: theme => theme.palette.mode === 'light' ? 'rgba(46, 125, 50, 0.2)' : 'rgba(46, 125, 50, 0.3)' - }}> - - Final Quiz Grade: - - - - - - Overall Tip: - - - - - {quiz.finalTip} - - - - - )} - - )} - - ); -}; - -export default Quiz; \ No newline at end of file + )} + + {/* Final Grade Display */} + {quizSubmitted && quiz.finalGrade !== undefined && ( + + + + + 🎉 Final Grade: {quiz.finalGrade}% {getGradeEmoji(quiz.finalGrade)} + + + + + {quiz.finalTip && ( + + + 🎯 Overall Feedback: + + + {quiz.finalTip} + + + )} + + + + + )} + + + + )} + + + ) +} + +export default Quiz diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx index 6b21eba..a0d4008 100644 --- a/nextstep-frontend/src/pages/Resume.tsx +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -12,7 +12,7 @@ import { CardMedia, CardContent, } from '@mui/material'; -import { styled } from '@mui/material/styles'; +import { styled, useTheme } from '@mui/material/styles'; import { config } from '../config'; import api from '../serverApi'; import ReactMarkdown from 'react-markdown'; @@ -194,6 +194,7 @@ const Resume: React.FC = () => { const [generatedResume, setGeneratedResume] = useState<{ content: string; type: string } | null>(null); const fileInputRef = useRef(null); const feedbackEndRef = useRef(null); + const theme = useTheme() useEffect(() => { if (feedbackEndRef.current) { @@ -517,7 +518,7 @@ const Resume: React.FC = () => { {loading && } {feedback && ( - + Analysis Feedback: From a54d0b79e772a0954e172ea1736b655ef0e6dd4c Mon Sep 17 00:00:00 2001 From: lina elman Date: Fri, 11 Jul 2025 20:46:50 +0300 Subject: [PATCH 16/23] removed linkedin from main dashboard & added missing from quiz page --- nextstep-frontend/src/components/LeftBar.tsx | 2 +- nextstep-frontend/src/pages/MainDashboard.tsx | 13 - nextstep-frontend/src/pages/Quiz.tsx | 472 ++++++++++-------- 3 files changed, 257 insertions(+), 230 deletions(-) diff --git a/nextstep-frontend/src/components/LeftBar.tsx b/nextstep-frontend/src/components/LeftBar.tsx index 001b46a..917ce97 100644 --- a/nextstep-frontend/src/components/LeftBar.tsx +++ b/nextstep-frontend/src/components/LeftBar.tsx @@ -119,7 +119,7 @@ const LeftBar: React.FC = () => { alt="NextStep" className="logo-text" sx={{ - height: collapsed ? 40 : 60, // Reduced logo size + height: collapsed ? 40 : 80, cursor: "pointer", opacity: 1, transform: collapsed ? "scale(0.8)" : "scale(1)", diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 9eb90a0..68c71fe 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -903,19 +903,6 @@ const MainDashboard: React.FC = () => { ) : ( - + + )} + + {/* Generated Quiz Display */} + {quiz && ( + + + Quiz on: + handleEditSubject(e.target.value)} + variant="outlined" + size="small" + sx={{ ml: 2, width: '50%' }} + /> + + + {/* --- Enhanced Display of Quiz Metadata --- */} + + + {quiz.title && ( + + + + Quiz Title: {quiz.title} - - - {/* ------------------ Subject field ------------------ */} - setSubject(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleGenerateQuiz()} - sx={{ - mb: 3, - "& .MuiOutlinedInput-root": { borderRadius: 2, fontSize: "1.1rem" }, - }} - /> - - {/* -------------- Specialization checkboxes -------------- */} - - - Specialization Focus (Optional): + + )} + + {quiz.jobRole && ( + + + + Job Role: {quiz.jobRole} + + + )} + + {quiz.companyNameEn && ( + + + + Company: {quiz.companyNameEn} + + + )} + + {quiz.tags && quiz.tags.length > 0 && ( + + + + Tags: + + + {quiz.tags.map((tag, i) => ( + + ))} + + + )} + + {quiz.specialty_tags && quiz.specialty_tags.length > 0 && ( + + + + Specialties: - - {([ - ["code", "💻 Code"], - ["design", "🎨 Design"], - ["technologies", "⚡ Technologies"], - ] as const).map(([key, label]) => ( - - setSelectedSpecialties((prev) => ({ ...prev, [key]: e.target.checked })) - } - sx={{ "& .MuiSvgIcon-root": { fontSize: 24 } }} - /> - } - label={{label}} + + {quiz.specialty_tags.map((specialty, i) => ( + ))} - - - - {/* ------------------- Generate button ------------------- */} - - - - - )} - - {/* ----------------------- Quiz display ----------------------- */} - {quiz && ( - - - - {/* --------------- Editable title ---------------- */} - - - Quiz: {subject} + + + )} + + {quiz.keywords && quiz.keywords.length > 0 && ( + + + + Keywords: - handleEditSubject(e.target.value)} - variant="outlined" - size="small" - sx={{ "& .MuiOutlinedInput-root": { borderRadius: 2 } }} - /> - - - {/* ----------------- Metadata block ---------------- */} - - - {/* ----- title ----- */} - {quiz.title && ( - - - - - {quiz.title} - - - - )} - - {/* ----- role / company ----- */} - {(quiz.jobRole || quiz.companyNameEn) && ( - - - {quiz.jobRole && ( - - - - - Role: {quiz.jobRole} - - - - )} - {quiz.companyNameEn && ( - - - - - Company: {quiz.companyNameEn} - - - - )} - - - )} - - {/* ----- tags ----- */} - {quiz.tags?.length && ( - - - 📌 Tags: - - - {quiz.tags.map((tag, i) => ( - - ))} - - - )} - - {/* ----- specialties ----- */} - {1 && ( - - - ⚡ Specialties: - - - {quiz.specialty_tags?.map((s, i) => ( - - ))} - - - )} - - {/* ----- keywords ----- */} - {quiz.keywords?.length && ( - - - - Keywords: - - - {quiz.keywords.map((kw, i) => ( - - ))} - - - )} - - {/* ----- process details ----- */} - {quiz.processDetails && ( - - - - - - Process Details: - - - {quiz.processDetails} - - - - - )} - - {/* ----- content / context ----- */} - {quiz.content && ( - - - - - - Context / Content: - - - {quiz.content} - - - - - )} - - {/* ----- interviewer mindset ----- */} - {quiz.interviewerMindset && ( - - - - - - Interviewer Mindset: - - - "{quiz.interviewerMindset}" - - - - - )} - - - - - - {/* ---------------------- Instructions --------------------- */} - + {quiz.keywords.map((keyword, i) => ( + + ))} + + + )} + + {quiz.processDetails && ( + + + + Process Details: + + + {quiz.processDetails} + + + )} + + {quiz.content && ( + + + + Context/Content: + + + {quiz.content} + + + )} + + {quiz.interviewerMindset && ( + + + + Interviewer Mindset: + + + "{quiz.interviewerMindset}" + + + )} + + + + {/* --- End Enhanced Display of Quiz Metadata --- */} + + + Your answers may get better grades for broad, in-depth explanations. You can answer in any language you want! + + {quiz.questions.map((q, index) => ( + + + {/* Circled Numbering (Option 2) */} + - - 💡 Tip: Provide detailed, comprehensive answers for better grades. You can answer in any language! - - - - {/* ------------------ Question list ------------------ */} - {quiz.questions.map((q, idx) => ( - - - {/* ----------- Question header with eye icon ---------- */} - - - - {idx + 1} - - - {q.originalQuestion} - - - {q.correctAnswer && ( - - handleToggleAnswerVisibility(idx)} - size="small" - sx={{ - ml: 2, - color: theme.palette.primary.main, - "&:hover": { backgroundColor: alpha(theme.palette.primary.main, 0.1) }, - }} - > - {showAnswer[idx] ? : } - - - )} - - - {/* ---------------- Answer textarea ---------------- */} - handleUserAnswerChange(idx, e.target.value)} - disabled={quizSubmitted} - sx={{ mb: 2, "& .MuiOutlinedInput-root": { borderRadius: 2 } }} - /> - - {/* ---------------- Grade bar ---------------- */} - {quizSubmitted && q.grade !== undefined && ( - - - - Your Grade: {getGradeEmoji(q.grade)} - - - - - - )} - - {/* ---------------- Improvement tip ---------------- */} - {quizSubmitted && q.tip && ( - - - - - - 💡 Improvement Tip: - - {q.tip} - - - - )} - - {/* ---------------- Correct answer ---------------- */} - {showAnswer[idx] && q.correctAnswer && ( - - - ✅ Correct Answer: - - {q.correctAnswer} - - )} - - - ))} - - {/* ---------------- Submit button ---------------- */} - {!quizSubmitted && ( - - - + {index + 1} + + {/* Question Text */} + + {q.originalQuestion} + + {/* Blinking Eye Icon (now always visible if answer exists) */} + {q.correctAnswer && ( + + handleToggleAnswerVisibility(index)} size="small" sx={{ flexShrink: 0, ml: 1 }}> + {showAnswer[index] ? : } + + )} - - {/* ---------------- Final grade ---------------- */} - {quizSubmitted && quiz.finalGrade !== undefined && ( - - - - - 🎉 Final Grade: {quiz.finalGrade}% {getGradeEmoji(quiz.finalGrade)} - - - - - {quiz.finalTip && ( - - - 🎯 Overall Feedback: - - - {quiz.finalTip} - - - )} - - - - - )} - - - - )} - - - ) -} - -export default Quiz + disabled + /> + + + + + Tip: + + + + + {q.tip} + + + + + )} + {/* Correct answer display is now independent of quizSubmitted for showing */} + {showAnswer[index] && q.correctAnswer && ( + theme.palette.mode === 'light' ? '#FFFFFF' : 'background.paper', + borderRadius: 1, + border: '1px solid', + borderColor: 'divider' + }}> + Correct Answer: + {q.correctAnswer} + + )} + + ))} + + {!quizSubmitted && ( + + )} + + {quizSubmitted && quiz.finalGrade !== undefined && ( + theme.palette.mode === 'light' ? '#e8f5e9' : 'rgba(46, 125, 50, 0.15)', + borderRadius: 2, + boxShadow: 2, + textAlign: 'center', + border: '1px solid', + borderColor: theme => theme.palette.mode === 'light' ? 'rgba(46, 125, 50, 0.2)' : 'rgba(46, 125, 50, 0.3)' + }}> + + Final Quiz Grade: + + + + + + Overall Tip: + + + + + {quiz.finalTip} + + + + + )} + + )} + + ); +}; + +export default Quiz; \ No newline at end of file From 8d88326c68ba572a647e54f1f7a8553df9bea401 Mon Sep 17 00:00:00 2001 From: lina elman Date: Thu, 17 Jul 2025 09:59:14 +0300 Subject: [PATCH 18/23] fixed build issues unnecessary imports --- nextstep-frontend/src/pages/MainDashboard.tsx | 1 - nextstep-frontend/src/pages/Resume.tsx | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 68c71fe..b48339e 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -26,7 +26,6 @@ import { } from "@mui/material" import { GitHub, - LinkedIn, Person as PersonIcon, Work as WorkIcon, Build as BuildIcon, diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx index 32f872a..964cf72 100644 --- a/nextstep-frontend/src/pages/Resume.tsx +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -1,9 +1,7 @@ -"use client" - import type React from "react" import { useState, useRef, useEffect } from "react" import { Box, Button, CircularProgress, Typography, TextField } from "@mui/material" -import { styled, useTheme } from "@mui/material/styles" +import { styled } from "@mui/material/styles" import { config } from "../config" import api from "../serverApi" import ReactMarkdown from "react-markdown" @@ -105,7 +103,6 @@ const Resume: React.FC = () => { const [error, setError] = useState("") const fileInputRef = useRef(null) const feedbackEndRef = useRef(null) - const theme = useTheme() const [fileName, setFileName] = useState("") useEffect(() => { From 1ed5852bcd29caea18b9aab4a3a33657388b9382 Mon Sep 17 00:00:00 2001 From: lina elman Date: Thu, 17 Jul 2025 10:27:08 +0300 Subject: [PATCH 19/23] added sync resume with main dashboard --- nextstep-frontend/src/pages/MainDashboard.tsx | 313 +++++++++++------ nextstep-frontend/src/pages/Resume.tsx | 325 ++++++++++++++---- 2 files changed, 468 insertions(+), 170 deletions(-) diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index b48339e..cd327b5 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -23,6 +23,7 @@ import { Card, CardContent, LinearProgress, + Alert, } from "@mui/material" import { GitHub, @@ -39,6 +40,7 @@ import { CheckCircle, InsertDriveFile, Delete, + Sync, } from "@mui/icons-material" import { connectToGitHub, initiateGitHubOAuth, fetchRepoLanguages, handleGitHubOAuth } from "../handlers/githubAuth" import api from "../serverApi" @@ -73,7 +75,7 @@ const skillsList = [ ] const MainDashboard: React.FC = () => { - const theme = useTheme(); + const theme = useTheme() const [aboutMe, setAboutMe] = useState(() => localStorage.getItem("aboutMe") || "") const [skills, setSkills] = useState(() => JSON.parse(localStorage.getItem("skills") || "[]")) const [newSkill, setNewSkill] = useState("") @@ -84,13 +86,16 @@ const MainDashboard: React.FC = () => { // AI-resume state 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 = 4 @@ -124,33 +129,47 @@ const MainDashboard: React.FC = () => { useEffect(() => { 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 + // Fetch resume data on mount and check for changes useEffect(() => { const fetchResumeData = async () => { try { - const response = await api.get('/resume'); - setResumeFileName(response.data.parsedData.fileName || ''); - setResumeExperience(response.data.parsedData.experience || []); - setRoleMatch(response.data.parsedData.roleMatch || ''); + 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); + console.error("Failed to fetch resume data:", err) } - }; - fetchResumeData(); - }, []); + } + fetchResumeData() + }, []) const mergeRepoLanguages = async (fetchedRepos: typeof repos) => { const langSet = new Set(skills) @@ -190,61 +209,86 @@ const MainDashboard: React.FC = () => { } } - 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(); - }, []); + 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 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; - }; - - const file = e.target.files?.[0]; - if (!file) return; - setResumeFileName(file.name); - setParsing(true); - const form = new FormData(); - form.append('file', file); + }) + 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 uploadedResume = await uploadResume(form); - - const res = await api.post('/resume/parseResume', - { - resumefileName: uploadedResume, originfilename: file.name, - }, { - 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) + 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) + } catch (err) { + console.error(err) + alert("Failed to sync with resume.") + } finally { + setSyncing(false) + } + } // Fetch Linkedin Jobs const fetchJobs = async (settings: { @@ -288,8 +332,8 @@ const MainDashboard: React.FC = () => { const profileCompletion = calculateProfileCompletion() const handleRemoveAllSkills = () => { - setSkills([]); - }; + setSkills([]) + } return ( @@ -302,9 +346,10 @@ const MainDashboard: React.FC = () => { sx={{ fontWeight: 800, display: "inline", - background: theme.palette.mode === "dark" - ? "linear-gradient(45deg, #60a5fa 30%, #34d399 90%)" - : "linear-gradient(45deg, #3b82f6 30%, #10b981 90%)", + background: + theme.palette.mode === "dark" + ? "linear-gradient(45deg, #60a5fa 30%, #34d399 90%)" + : "linear-gradient(45deg, #3b82f6 30%, #10b981 90%)", backgroundClip: "text", WebkitBackgroundClip: "text", color: "transparent", @@ -318,6 +363,27 @@ const MainDashboard: React.FC = () => { Your personalized career development dashboard + {/* Resume Change Alert */} + {hasResumeChanged && ( + : } + onClick={handleSyncWithNewResume} + disabled={syncing} + > + {syncing ? "Syncing..." : "Sync Now"} + + } + > + Your resume has been updated. Click "Sync Now" to update your profile with the latest information. + + )} + {/* Profile Completion Card */} { transform: "translateY(-4px)", boxShadow: `0 12px 48px ${alpha(theme.palette.common.black, 0.15)}`, }, - // flexDirection: "column", }} > {/* Upload Section */} - { !image ? - - : - - - } + {!image ? ( + + + + ) : ( + + )} About Me @@ -407,6 +477,37 @@ const MainDashboard: React.FC = () => { {parsing && } + {hasResumeChanged && ( + + + {syncing ? : } + + + )} { transition: "all 0.2s ease", }} > - {resumeFileName ? - - - : } + {resumeFileName ? ( + + + + + ) : ( + + )} @@ -474,7 +581,7 @@ const MainDashboard: React.FC = () => { {/* Role and Skills Row */} - + {/* Skills */} { border: `1px solid ${alpha(theme.palette.divider, 0.1)}`, boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`, transition: "all 0.3s ease", - position: 'relative', // Add position relative + position: "relative", "&:hover": { transform: "translateY(-4px)", boxShadow: `0 12px 48px ${alpha(theme.palette.common.black, 0.15)}`, @@ -520,8 +627,12 @@ const MainDashboard: React.FC = () => { - @@ -619,7 +730,7 @@ const MainDashboard: React.FC = () => { }, }} > - + ({ - border: "2px dashed #ccc", - borderRadius: "8px", - padding: "20px", + 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)", }, })) @@ -28,6 +48,7 @@ const FeedbackContainer = styled(Box)(({ theme }) => ({ borderRadius: theme.shape.borderRadius, textAlign: "left", color: theme.palette.text.primary, + 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, @@ -95,15 +116,19 @@ const FeedbackContainer = styled(Box)(({ theme }) => ({ })) const Resume: React.FC = () => { + 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) { @@ -122,12 +147,17 @@ const Resume: React.FC = () => { 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() }, []) @@ -178,9 +208,11 @@ const Resume: React.FC = () => { 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 token = localStorage.getItem(config.localStorageKeys.userAuth) @@ -230,87 +262,242 @@ const Resume: React.FC = () => { } } + 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) + } + + // 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", - }, - }} - /> - - - - - {fileName ? {fileName} : Click to upload your resume} - - - {error && ( - - {error} + + + + Resume Analyzer - )} - + - - - + - {loading && } + + Upload your resume and a job description to get personalized feedback and a match score. + - {feedback && ( - - - Analysis Feedback: + + + + Job Description - - {feedback} -
- + + setJobDescription(e.target.value)} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, + }} + /> + + + + Resume + + + + + + {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" }} + /> ) } From e374be910fc22f100755c91ab4f224d91c415a9f Mon Sep 17 00:00:00 2001 From: lina elman Date: Fri, 8 Aug 2025 13:39:27 +0300 Subject: [PATCH 20/23] added aboutme, skills and role to backend --- .../src/controllers/users_controller.ts | 19 ++++ nextstep-backend/src/models/user_model.ts | 17 +++- nextstep-backend/src/routes/users_routes.ts | 2 +- .../src/services/users_service.ts | 8 ++ nextstep-backend/src/types/user_types.ts | 3 + nextstep-frontend/src/pages/MainDashboard.tsx | 87 +++++++++++++++---- 6 files changed, 118 insertions(+), 18 deletions(-) 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/user_model.ts b/nextstep-backend/src/models/user_model.ts index 62c232e..7e0e81a 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, imageFilename: ret?.imageFilename, createdAt: ret.createdAt, - updatedAt: ret.updatedAt + updatedAt: ret.updatedAt, + aboutMe: ret?.aboutMe, + skills: ret?.skills, + selectedRole: ret?.selectedRole, }; } }); 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/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/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-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index cd327b5..117d569 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -76,10 +76,10 @@ const skillsList = [ const MainDashboard: React.FC = () => { const theme = useTheme() - const [aboutMe, setAboutMe] = useState(() => localStorage.getItem("aboutMe") || "") - const [skills, setSkills] = useState(() => JSON.parse(localStorage.getItem("skills") || "[]")) + const [aboutMe, setAboutMe] = useState("") + const [skills, setSkills] = useState([]) const [newSkill, setNewSkill] = useState("") - const [selectedRole, setSelectedRole] = useState(() => localStorage.getItem("selectedRole") || "") + 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) @@ -114,17 +114,6 @@ const MainDashboard: React.FC = () => { 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]) - // Handle GitHub OAuth callback useEffect(() => { const code = new URLSearchParams(window.location.search).get("code") @@ -146,6 +135,13 @@ const MainDashboard: React.FC = () => { 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 @@ -184,11 +180,13 @@ const MainDashboard: React.FC = () => { 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)) + setIsProfileDirty(true); } const handleGitHubConnect = async () => { @@ -256,6 +254,7 @@ const MainDashboard: React.FC = () => { setResumeExperience(aiExp) setCurrentResumeId(uploadedResume) localStorage.setItem("lastResumeId", uploadedResume) + updateUserProfile(aiAbout, aiSkills, aiRole); setHasResumeChanged(false) } catch (err) { console.error(err) @@ -282,6 +281,7 @@ const MainDashboard: React.FC = () => { setResumeExperience(aiExp) localStorage.setItem("lastResumeId", currentResumeId) setHasResumeChanged(false) + updateUserProfile(aiAbout, aiSkills, aiRole); } catch (err) { console.error(err) alert("Failed to sync with resume.") @@ -333,8 +333,52 @@ const MainDashboard: React.FC = () => { 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}`, + }, + }); + // // 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("Failed to update profile:", error); + alert("Failed to update profile. Please try again."); + } finally { + setIsSaving(false); + } + }; + return ( @@ -560,7 +604,7 @@ const MainDashboard: React.FC = () => { variant="outlined" placeholder="Describe your background, experience, and career aspirations..." value={aboutMe} - onChange={(e) => setAboutMe(e.target.value)} + onChange={handleAboutMeChange} sx={{ "& .MuiOutlinedInput-root": { borderRadius: 3, @@ -756,7 +800,7 @@ const MainDashboard: React.FC = () => { freeSolo options={roles} value={selectedRole} - onInputChange={(_, val) => setSelectedRole(val)} + onInputChange={handleSelectedRoleChange} renderInput={(params) => ( { + {isProfileDirty && ( + + )} ) From 173559273cb52e0cf4c0a578789c64b5e979659e Mon Sep 17 00:00:00 2001 From: lina elman Date: Fri, 8 Aug 2025 13:58:00 +0300 Subject: [PATCH 21/23] added small fix --- nextstep-frontend/src/pages/MainDashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 117d569..68e6c8d 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -254,7 +254,7 @@ const MainDashboard: React.FC = () => { setResumeExperience(aiExp) setCurrentResumeId(uploadedResume) localStorage.setItem("lastResumeId", uploadedResume) - updateUserProfile(aiAbout, aiSkills, aiRole); + updateUserProfile(aiAbout, aiSkills, selectedRole); setHasResumeChanged(false) } catch (err) { console.error(err) @@ -281,7 +281,7 @@ const MainDashboard: React.FC = () => { setResumeExperience(aiExp) localStorage.setItem("lastResumeId", currentResumeId) setHasResumeChanged(false) - updateUserProfile(aiAbout, aiSkills, aiRole); + updateUserProfile(aiAbout, aiSkills, selectedRole); } catch (err) { console.error(err) alert("Failed to sync with resume.") From ffd6df5a333236aef6b1797c5a6f52e503744b4d Mon Sep 17 00:00:00 2001 From: lina elman Date: Tue, 19 Aug 2025 12:49:30 +0300 Subject: [PATCH 22/23] fixed quiz response not parsing correctly --- nextstep-backend/src/services/companies_service.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 || []; From dc69b1999a7bce0077bb25fff96076cd954a8474 Mon Sep 17 00:00:00 2001 From: lina elman Date: Tue, 19 Aug 2025 13:07:38 +0300 Subject: [PATCH 23/23] fixed issue text box dissapearing --- nextstep-frontend/src/components/NewPost.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextstep-frontend/src/components/NewPost.tsx b/nextstep-frontend/src/components/NewPost.tsx index 31c4632..2284080 100644 --- a/nextstep-frontend/src/components/NewPost.tsx +++ b/nextstep-frontend/src/components/NewPost.tsx @@ -318,7 +318,7 @@ const NewPostModal: React.FC = ({ open, onClose, onPostCreate /> - {open && mountedEditor && content && ( + {open && mountedEditor && (