diff --git a/backend/user-service/README.md b/backend/user-service/README.md index 17418b7e6f..ec15dee12b 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -477,6 +477,7 @@ Records a question attempt for a user, either creating a new record or updating "questionId": "18", "timeSpent": 120, // Time spent on the question in seconds "code": "function solve() { ... }" // User's code submission for the attempt + "language": "javascript" } - **Response Format** (JSON): - **Code**: 200 @@ -519,6 +520,7 @@ Retrieve all details for a particular question attempt made by a user. "attemptCount": 3, "attemptTime": 30, "code": "function add(x, y){\r\n return x + y;\r\n}\r\n\r\nfunction substract(x, y) {\r\n return x - y;\r\n}\r\n\r\nconst addition = add(1, 2);\r\n", + "language": "javascript" "question": { "id": 19, "title": "Chalkboard XOR Game", diff --git a/backend/user-service/controller/user-controller.js b/backend/user-service/controller/user-controller.js index 65595a0be2..de57ce516a 100644 --- a/backend/user-service/controller/user-controller.js +++ b/backend/user-service/controller/user-controller.js @@ -248,8 +248,8 @@ export async function getUserHistory(req, res) { export async function addQuestionAttempt(req, res) { try { const userId = req.params.userId; - const { questionId, timeSpent, code} = req.body; - + const { questionId, timeSpent, code, language} = req.body; + console.log("language received: ", language) const parsedId = Number(questionId); console.log(parsedId); @@ -273,7 +273,7 @@ export async function addQuestionAttempt(req, res) { console.log("Found question:", question); // Add or update the question attempt in the user's question history - const updated = await _addOrUpdateQuestionHistory(userId, question._id, timeSpent, code); + const updated = await _addOrUpdateQuestionHistory(userId, question._id, timeSpent, code, language); if (updated) { return res.status(200).json({ message: "Question history updated successfully." }); diff --git a/backend/user-service/model/questionHistory.js b/backend/user-service/model/questionHistory.js index dd9906cd7a..8a239efec1 100644 --- a/backend/user-service/model/questionHistory.js +++ b/backend/user-service/model/questionHistory.js @@ -27,6 +27,10 @@ const QuestionHistorySchema = new mongoose.Schema({ code: { type: String, required: true, + }, + language: { + type: String, + required: true, } }); diff --git a/backend/user-service/model/repository.js b/backend/user-service/model/repository.js index 0bcb7bf721..f10d996cee 100644 --- a/backend/user-service/model/repository.js +++ b/backend/user-service/model/repository.js @@ -164,9 +164,9 @@ export async function getTotalQuestionsAvailable() { return response.data.length; } -export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent, code) { +export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent, code, language) { try { - console.log("Received data in addOrUpdateQuestionHistory:", { userId, questionId, timeSpent, code}); + console.log("Received data in addOrUpdateQuestionHistory:", { userId, questionId, timeSpent, code, language}); // Try to find an existing record console.log("Attempting to find existing history with userId and questionId..."); @@ -180,6 +180,7 @@ export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent, existingHistory.attemptTime += timeSpent; existingHistory.attemptDate = new Date(); existingHistory.code = code; + existingHistory.language = language; // Try to save the updated document await existingHistory.save(); @@ -196,6 +197,7 @@ export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent, attemptCount: 1, attemptTime: timeSpent, code: code, + language: language }); // Try to save the new document @@ -229,6 +231,7 @@ export async function findQuestionAttemptDetails(userId, questionId) { attemptCount: attempt.attemptCount, attemptTime: attempt.attemptTime, code: attempt.code, + language: attempt.language, question: questionDetails ? { id: questionDetails.id, title: questionDetails.title, diff --git a/frontend/app/(authenticated)/profile/question-history/code/page.tsx b/frontend/app/(authenticated)/profile/question-history/code/page.tsx new file mode 100644 index 0000000000..7bab4bc42d --- /dev/null +++ b/frontend/app/(authenticated)/profile/question-history/code/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useRef, useState } from 'react'; +import { Copy, Flag, MessageSquareText } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Toaster } from "@/components/ui/sonner" +import { useSearchParams } from 'next/navigation'; +import { getCookie, setCookie } from '@/app/utils/cookie-manager'; +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from '@/components/ui/badge'; +import Editor from "@monaco-editor/react"; +import { toast } from "sonner" +import Markdown from 'react-markdown' + +type Question = { + id: number; + title: string; + complexity: string; + category: string[]; + description: string; + link: string; +} + +function getTimeAgo(attemptDate: Date | null) { + const now = new Date(); + + const diffInMs = now - attemptDate; // Difference in milliseconds + const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); + const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); + const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); + + if (diffInDays > 0) { + return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`; + } else if (diffInHours > 0) { + return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`; + } else if (diffInMinutes > 0) { + return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`; + } else { + return "1 minute ago"; + } +} + +export default function CodeViewer() { + const searchParams = useSearchParams(); + const questionId = searchParams.get("questionId"); + const [attemptDate, setAttemptDate] = useState(null); + const [questionDetails, setQuestionDetails] = useState({ + "id": 1, + "title": "Question Title", + "complexity": "Easy", + "category": ["Arrays", "Algorithms"], + "description": "question details", + "link": "" + }); + const [code, setCode] = useState(""); + const [language, setLanguage] = useState("javascript") + const userId = useRef(null); + + useEffect(() => { + const fetchAttemptDetails = async () => { + try { + userId.current = getCookie('userId'); + + if (!userId.current) { + // Call the API to get user id + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${getCookie('token')}`, + }, + }); + + const data = (await response.json()).data; + setCookie('userId', data.id, { 'max-age': '86400', 'path': '/', 'SameSite': 'Strict' }); + } + + console.log("In question history page: call api to fetch user past atttempted code") + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_HISTORY_URL}/${userId.current}/question/${questionId}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${getCookie('token')}`, + }, + } + ); + + const data = await response.json(); + if (!response.ok) { + console.error("Error:", data.message); + throw Error("Error happen when calling backend API"); + } + + setQuestionDetails({ + id: data.question.id, + title: data.question.title, + complexity: data.question.complexity, + category: data.question.category.sort((a: string, b: string) => + a.localeCompare(b) + ), + description: data.question.description, + link: data.question.link, + }) + setAttemptDate(new Date(data.attemptDate)); + setCode(JSON.parse(data.code)) + setLanguage(data.language) + } catch (err) { + console.error(err.message); + toast.dismiss(); + toast.error("Failed to load the code. Please try again later."); + } + }; + + fetchAttemptDetails(); + }, [questionId]); + + const handleEditorDidMount = (editor) => { + editor.getDomNode().classList.add("my-custom-editor"); + + // Insert scoped tooltip styles + const style = document.createElement("style"); + style.textContent = ` + .my-custom-editor .monaco-tooltip { + z-index: 1000 !important; + position: absolute; + } + `; + document.head.appendChild(style); + + // Cleanup on component unmount + return () => { + document.head.removeChild(style); + }; +}; + + const copyToClipboard = () => { + navigator.clipboard.writeText(code).then(() => { + toast.dismiss(); + toast.success('Code copied to clipboard!'); + }); + }; + + return ( +
+ {/* Left Panel: Question Details */} + +

+ {questionDetails?.title || ""} +

+
+
+ + + {questionDetails?.complexity || ""} + +
+
+ + {questionDetails?.category?.length > 0 && + questionDetails?.category.map((category) => ( + + {category} + + ))} +
+
+

+ {questionDetails?.description || ""} +

+ + {questionDetails?.description || ""} + +
+ + {/* Right Panel: Code Display */} +
+
+
+ {code.split('\n').length} lines {attemptDate ? ` • Attempted ${getTimeAgo(attemptDate)}` : ""} +
+ +
+ + {/* Editor Wrapper */} +
+ +
+
+ + +
+ ); +} diff --git a/frontend/app/(authenticated)/profile/question-history/columns.tsx b/frontend/app/(authenticated)/profile/question-history/columns.tsx index f6dca6a990..26d731d98f 100644 --- a/frontend/app/(authenticated)/profile/question-history/columns.tsx +++ b/frontend/app/(authenticated)/profile/question-history/columns.tsx @@ -4,7 +4,9 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { ColumnDef } from "@tanstack/react-table" -import { AlignLeft, ArrowUpDown } from "lucide-react" +import { AlignLeft, ArrowUpDown, MoreHorizontal } from "lucide-react" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import Link from "next/link" export type QuestionHistory = { id: number; @@ -151,7 +153,7 @@ export const columns : ColumnDef[]= [ ) }, accessorKey: "attemptTime", - cell: ({ row }) =>
{ Math.floor(row.getValue("attemptTime")/60)}
, + cell: ({ row }) =>
{ Math.ceil(row.getValue("attemptTime")/60)}
, // Cell: ({ value }) => Math.floor(value / 60), // Convert time spent in seconds to minutes }, { @@ -167,6 +169,43 @@ export const columns : ColumnDef[]= [ ) }, accessorKey: "attemptDate", - cell: ({ row }) => row.getValue("attemptDate").toLocaleString(), + cell: ({ row }) => { + const attemptDate = row.getValue("attemptDate"); + return new Date(attemptDate).toLocaleString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + }, }, + { + id: "actions", + cell: ({ row }) => { + return ( + + + + + + + + View Code + + + + + ) + }, + enableResizing: false, + } ]; \ No newline at end of file diff --git a/frontend/app/(authenticated)/session/[id]/page.tsx b/frontend/app/(authenticated)/session/[id]/page.tsx index d0ad280d29..106606897a 100644 --- a/frontend/app/(authenticated)/session/[id]/page.tsx +++ b/frontend/app/(authenticated)/session/[id]/page.tsx @@ -46,7 +46,9 @@ export default function Session() { const [timeElapsed, setTimeElapsed] = useState(0); const [isSessionEnded, setIsSessionEnded] = useState(false); const [isEndDialogOpen, setIsEndDialogOpen] = useState(false); + const [language, setLanguage] = useState("javascript"); + const codeDocRef = useRef(); const codeProviderRef = useRef(null); const notesProviderRef = useRef(null); @@ -79,7 +81,56 @@ export default function Session() { } catch (error) { console.error('Failed to parse matchResult:', error); } - } + } + + const callUserHistoryAPI = useCallback(async () => { + if (isHistoryApiCalled) return; + + setIsHistoryApiCalled(true); + + const abortController = new AbortController(); + setController(abortController); + setIsEndingSession(true); + + const codeText = codeDocRef.current.getText(`monaco`); + const code = codeText.toString(); + console.log("languge: ", language) + try { + await fetch(`${process.env.NEXT_PUBLIC_USER_API_HISTORY_URL}/${getCookie('userId')}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${getCookie('token')}`, + }, + body: JSON.stringify({ + userId: getCookie('userId'), + questionId: questionId, + timeSpent: timeElapsed, + code: JSON.stringify(code), + language: language, + }), + signal: abortController.signal, + }); + } catch (error) { + console.error('Failed to update question history:', error); + setIsHistoryApiCalled(false); + } finally { + setIsEndingSession(false); + setController(null); + } + }, [isHistoryApiCalled, timeElapsed]); + + useEffect(() => { + if (isSessionEnded && !isHistoryApiCalled) { + const cleanup = async () => { + await callUserHistoryAPI(); + setTimeout(() => { + router.push('/questions'); + }, 3000); + }; + cleanup(); + } + }, [isSessionEnded, isHistoryApiCalled, callUserHistoryAPI, router]); useEffect(() => { setIsClient(true); @@ -110,11 +161,11 @@ export default function Session() { url: process.env.NEXT_PUBLIC_COLLAB_API_URL || 'ws://localhost:3003' }); - const codeDoc = new Y.Doc(); + codeDocRef.current = new Y.Doc(); const codeProvider = new HocuspocusProvider({ websocketProvider: socket, name: `code-${params.id}`, - document: codeDoc, + document: codeDocRef.current, token: 'abc', onConnect: () => { console.log('Connected to code server'); @@ -155,49 +206,6 @@ export default function Session() { }; }, [isSessionEnded, params.id, questionId, router]); - const callUserHistoryAPI = useCallback(async () => { - if (isHistoryApiCalled) return; - - setIsHistoryApiCalled(true); - - const abortController = new AbortController(); - setController(abortController); - setIsEndingSession(true); - - try { - await fetch(`${process.env.NEXT_PUBLIC_USER_API_HISTORY_URL}/${getCookie('userId')}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${getCookie('token')}`, - }, - body: JSON.stringify({ - userId: getCookie('userId'), - questionId: "1", - timeSpent: timeElapsed, - }), - signal: abortController.signal, - }); - } catch (error) { - console.error('Failed to update question history:', error); - setIsHistoryApiCalled(false); - } finally { - setIsEndingSession(false); - setController(null); - } - }, [isHistoryApiCalled, timeElapsed]); - - useEffect(() => { - if (isSessionEnded && !isHistoryApiCalled) { - const cleanup = async () => { - await callUserHistoryAPI(); - setTimeout(() => { - router.push('/questions'); - }, 3000); - }; - cleanup(); - } - }, [isSessionEnded, isHistoryApiCalled, callUserHistoryAPI, router]); if (!isClient) { return SessionLoading(); @@ -227,6 +235,10 @@ export default function Session() { } } + const updateLanguage = (newLang: string) => { + setLanguage(newLang); + }; + return (
@@ -341,7 +353,7 @@ export default function Session() { - + diff --git a/frontend/app/(authenticated)/session/code-editor/code-editor.tsx b/frontend/app/(authenticated)/session/code-editor/code-editor.tsx index 4610f345fd..009a139853 100644 --- a/frontend/app/(authenticated)/session/code-editor/code-editor.tsx +++ b/frontend/app/(authenticated)/session/code-editor/code-editor.tsx @@ -14,9 +14,10 @@ import { langs } from './lang-loader'; interface CodeEditorProps { sessionId: string; provider: HocuspocusProvider; + setLanguage: (language: string) => void; } -export default function CodeEditor({ sessionId, provider }: CodeEditorProps) { +export default function CodeEditor({ sessionId, provider, setLanguage }: CodeEditorProps) { const editorRef = useRef(null); const bindingRef = useRef(); const monaco = useMonaco(); @@ -164,6 +165,7 @@ export default function CodeEditor({ sessionId, provider }: CodeEditorProps) { onSelect={(currentValue) => { monaco?.editor.setModelLanguage(editorRef.current!.getModel()!, currentValue); setLang(currentValue); + setLanguage(currentValue); setLangOpen(false); }} > @@ -226,4 +228,4 @@ export default function CodeEditor({ sessionId, provider }: CodeEditorProps) { />
); -} +} \ No newline at end of file