diff --git a/app/Http/Controllers/CourseAssessmentController.php b/app/Http/Controllers/CourseAssessmentController.php new file mode 100644 index 0000000..fff6f01 --- /dev/null +++ b/app/Http/Controllers/CourseAssessmentController.php @@ -0,0 +1,186 @@ +assessment_quiz_id) { + Log::warning('Attempted assessment for course without one.', ['course_id' => $course->id]); + + return Redirect::route('courses.show', $course)->with('info', 'This course does not have a placement assessment.'); + } + + $quiz = Quiz::with(['questions' => function ($query): void { + $query->select(['id', 'quiz_id', 'lesson_id', 'type', 'text', 'options', 'order']) + ->orderBy('order'); + }]) + ->find($course->assessment_quiz_id); + + if (! $quiz || $quiz->type !== 'assessment') { + Log::error('Assessment quiz not found or invalid type for course.', ['course_id' => $course->id, 'quiz_id' => $course->assessment_quiz_id]); + + return Redirect::route('courses.show', $course)->with('error', 'Assessment quiz not found.'); + } + + $processedQuestions = $quiz->questions->map(function ($question): array { + $optionsArray = []; + if (! empty($question->options)) { + $optionsArray = is_array($question->options) + ? $question->options + : (json_decode($question->options, true) ?? []); + } + + return [ + 'id' => $question->id, + 'quiz_id' => $question->quiz_id, + 'lesson_id' => $question->lesson_id, + 'type' => $question->type, + 'text' => $question->text, + 'options' => $optionsArray, + 'order' => $question->order, + ]; + }); + + $quizDataForView = $quiz->only('id', 'title', 'description'); + $quizDataForView['questions'] = $processedQuestions; + + return Inertia::render('courses/assessment/Show', [ + 'course' => $course->only('id', 'title', 'slug'), + 'quiz' => $quizDataForView, + ]); + } + + /** + * Process the assessment submission and provide recommendation. + */ + public function submit(Request $request, Course $course): Response|RedirectResponse + { + Auth::user(); + $submittedAnswers = $request->input('answers', []); + + $request->validate([ + 'answers' => 'required|array', + 'answers.*' => 'required', + ]); + + if (! $course->assessment_quiz_id) { + return Redirect::route('courses.show', $course)->with('error', 'Assessment configuration missing.'); + } + $assessmentQuiz = Quiz::find($course->assessment_quiz_id); + if (! $assessmentQuiz) { + return Redirect::route('courses.show', $course)->with('error', 'Assessment quiz not found for grading.'); + } + $submittedQuestionIds = array_keys($submittedAnswers); + $questions = Question::where('quiz_id', $assessmentQuiz->id) + ->whereIn('id', $submittedQuestionIds) + ->select(['id', 'lesson_id', 'options', 'correct_answer', 'type']) + ->get() + ->keyBy('id'); + + $correctCount = 0; + $totalQuestionsGraded = 0; + $resultsByLesson = []; + + foreach ($submittedAnswers as $questionId => $userAnswer) { + if (! $questions->has($questionId)) { + continue; + } + + $question = $questions->get($questionId); + $isCorrect = false; + $correctAnswer = $question->correct_answer; + + switch ($question->type) { + case 'multiple_choice': + case 'fill_blank': + case 'true_false': + if (is_string($userAnswer) && is_string($correctAnswer)) { + $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower(mb_trim($correctAnswer)); + } + break; + } + + if ($isCorrect) { + $correctCount++; + } + + if ($question->lesson_id) { + if (! isset($resultsByLesson[$question->lesson_id])) { + $resultsByLesson[$question->lesson_id] = ['correct' => 0, 'total' => 0]; + } + $resultsByLesson[$question->lesson_id]['total']++; + if ($isCorrect) { + $resultsByLesson[$question->lesson_id]['correct']++; + } + } + $totalQuestionsGraded++; + } + + $score = ($totalQuestionsGraded > 0) ? round(($correctCount / $totalQuestionsGraded) * 100) : 0; + $recommendedLessonSlug = null; + $passThreshold = 80; + + $courseLessons = $course->load(['modules.lessons' => function ($q): void { + $q->orderBy('order'); + }, 'modules' => function ($q): void { + $q->orderBy('order'); + }]) + ->modules->pluck('lessons')->flatten()->sortBy('id'); + + $firstLessonSlug = $courseLessons->first()?->slug; + + foreach ($courseLessons as $lesson) { + if (isset($resultsByLesson[$lesson->id])) { + $lessonData = $resultsByLesson[$lesson->id]; + $lessonScore = ($lessonData['total'] > 0) ? ($lessonData['correct'] / $lessonData['total'] * 100) : 100; // Assume pass if no questions asked + + if ($lessonScore < $passThreshold) { + $recommendedLessonSlug = $lesson->slug; + break; + } + } else { + $recommendedLessonSlug = $lesson->slug; + Log::info("Assessment Recommendation: Recommending lesson {$lesson->id} as it had no assessment questions."); + break; + } + } + + if ($recommendedLessonSlug === null && $totalQuestionsGraded > 0) { + $recommendationMessage = 'Great job! You seem to have a strong grasp of the topics covered in this assessment. Feel free to review any topic or jump right into the later modules.'; + $recommendedLessonSlug = $firstLessonSlug; + } elseif ($recommendedLessonSlug && $recommendedLessonSlug !== $firstLessonSlug) { + $recommendedLesson = $courseLessons->firstWhere('slug', $recommendedLessonSlug); + $recommendationMessage = "Based on your results, we recommend starting with the lesson '{$recommendedLesson?->title}'. However, you're welcome to start from the beginning for a full review."; + } else { + $recommendationMessage = 'Assessment complete. You can start with the first lesson.'; + $recommendedLessonSlug = $firstLessonSlug; + } + + return Inertia::render('courses/assessment/Result', [ + 'course' => $course->only('id', 'title', 'slug'), + 'score' => $score, + 'recommendationMessage' => $recommendationMessage, + 'recommendedLessonSlug' => $recommendedLessonSlug, + 'firstLessonSlug' => $firstLessonSlug, + ]); + } +} diff --git a/app/Http/Controllers/CourseController.php b/app/Http/Controllers/CourseController.php index 521a672..5553457 100644 --- a/app/Http/Controllers/CourseController.php +++ b/app/Http/Controllers/CourseController.php @@ -29,7 +29,6 @@ public function index(): Response */ public function show(Course $course): Response { - // Ensure only published courses are shown if (! $course->is_published) { abort(404); } @@ -41,7 +40,7 @@ public function show(Course $course): Response $query->select('id', 'module_id', 'title', 'description', 'order') ->orderBy('order'); }, - 'modules' => function($query): void{ + 'modules' => function ($query): void { $query->select('id', 'course_id', 'title', 'order'); }]); diff --git a/app/Http/Controllers/CourseReviewController.php b/app/Http/Controllers/CourseReviewController.php new file mode 100644 index 0000000..5843858 --- /dev/null +++ b/app/Http/Controllers/CourseReviewController.php @@ -0,0 +1,241 @@ +finalReviewQuiz; + if (! $placeholderQuiz) { + Log::warning("CourseReview: Course {$course->id} has no final_review_quiz_id linked."); + $quizTitle = "{$course->title} - Final Review"; + $quizDescription = "A comprehensive review of topics from {$course->title}."; + } else { + $quizTitle = $placeholderQuiz->title; + $quizDescription = $placeholderQuiz->description; + } + + $moduleIds = $course->modules()->pluck('id'); + $courseQuizIds = Quiz::whereIn('module_id', $moduleIds)->where('type', 'module')->pluck('id'); + + $incorrectQuestionIds = QuizAnswer::join('quiz_attempts', 'quiz_answers.quiz_attempt_id', '=', 'quiz_attempts.id') + ->where('quiz_attempts.user_id', $user->id) + ->whereIn('quiz_attempts.quiz_id', $courseQuizIds) + ->where('quiz_answers.is_correct', false) + ->distinct() + ->pluck('quiz_answers.question_id'); + + $wrongQuestions = Question::whereIn('id', $incorrectQuestionIds) + ->inRandomOrder() + ->take(self::REVIEW_QUIZ_WRONG_QUESTIONS_TARGET) + ->get(); + + $allLessonIdsInCourse = $course->modules->pluck('lessons')->flatten()->pluck('id'); + $remainingQuestionsTarget = self::REVIEW_QUIZ_NEW_QUESTIONS_TARGET + (self::REVIEW_QUIZ_WRONG_QUESTIONS_TARGET - $wrongQuestions->count()); + + $newQuestions = collect(); + if ($remainingQuestionsTarget > 0 && $allLessonIdsInCourse->isNotEmpty()) { + $newQuestions = Question::whereIn('lesson_id', $allLessonIdsInCourse) + ->whereNotIn('id', $wrongQuestions->pluck('id')) + ->inRandomOrder() + ->take($remainingQuestionsTarget) + ->get(); + } + + $finalQuestions = $wrongQuestions->concat($newQuestions)->shuffle(); + + if ($finalQuestions->isEmpty()) { + return Inertia::render('Courses/Review/Show', [ + 'course' => $course->only('id', 'title', 'slug'), + 'quizData' => null, + 'message' => 'Not enough questions available for a review quiz for this course yet. Try completing more module quizzes.', + ]); + } + + $displayQuestions = $finalQuestions->map(function ($q): array { + $optionsArray = is_array($q->options) ? $q->options : (json_decode($q->options, true) ?? []); + + return [ + 'id' => $q->id, + 'lesson_id' => $q->lesson_id, + 'type' => $q->type, + 'text' => $q->text, + 'options' => $optionsArray, + ]; + }); + + $quizDataForView = [ + 'id' => 'final_review_'.$course->id.'_'.uniqid(), + 'title' => $quizTitle, + 'description' => $quizDescription, + 'questions' => $displayQuestions, + ]; + + session(['_courseReviewQuestions_'.$course->id => $finalQuestions->keyBy('id')->toArray()]); + + return Inertia::render('courses/review/Show', [ + 'course' => $course->only('id', 'title', 'slug'), + 'quizData' => $quizDataForView, + 'message' => null, + ]); + } + + /** + * Process and grade the submitted final review quiz. + */ + public function submit(Request $request, Course $course): Response|RedirectResponse + { + $user = Auth::user(); + $submittedAnswers = $request->input('answers', []); + $request->input('quizId'); + + $request->validate(['answers' => 'required|array', 'quizId' => 'required|string']); + + $correctQuestionsData = session('_courseReviewQuestions_'.$course->id); + session()->forget('_courseReviewQuestions_'.$course->id); + + if (! $correctQuestionsData) { + return Redirect::route('course.review.generate', $course) + ->with('error', 'Review quiz session expired. Please try again.'); + } + $correctQuestions = collect($correctQuestionsData); + + $attempt = null; + $resultsData = []; + $correctCount = 0; + $processedAnswersCount = 0; + + try { + DB::transaction(function () use ( + $user, $course, $submittedAnswers, $correctQuestions, + &$attempt, &$correctCount, &$processedAnswersCount, &$resultsData + ): void { + $attempt = QuizAttempt::create([ + 'user_id' => $user->id, + 'quiz_id' => $course->final_review_quiz_id, + 'type' => QuizAttempt::TYPE_FINAL_REVIEW, + 'started_at' => now(), + 'completed_at' => now(), + ]); + + foreach ($submittedAnswers as $questionId => $userAnswer) { + if (! $correctQuestions->has($questionId)) { + continue; + } + + $questionData = $correctQuestions->get($questionId); + $question = is_array($questionData) ? (object) $questionData : $questionData; + + $optionsArray = (isset($question->options) && is_string($question->options)) + ? (json_decode($question->options, true) ?? []) + : ($question->options ?? []); + + $isCorrect = false; + $correctAnswer = $question->correct_answer ?? null; + + // TODO: Refactor the grading logic to Service/Trait later + switch ($question->type ?? 'multiple_choice') { + case 'multiple_choice': + case 'fill_blank': + case 'true_false': + if (is_string($userAnswer) && is_string($correctAnswer)) { + $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower(mb_trim($correctAnswer)); + } + break; + } + + if ($isCorrect) { + $correctCount++; + } + $processedAnswersCount++; + + QuizAnswer::create([ + 'quiz_attempt_id' => $attempt->id, + 'question_id' => $question->id, + 'user_answer' => $userAnswer, + 'is_correct' => $isCorrect, + ]); + + $resultsData[] = [ + 'question_id' => $question->id, + 'question_type' => $question->type ?? 'multiple_choice', + 'question_text' => $question->text ?? 'N/A', + 'options' => $optionsArray, + 'user_answer' => $userAnswer, + 'correct_answer' => $correctAnswer ?? 'N/A', + 'is_correct' => $isCorrect, + 'explanation' => $question->explanation ?? '', + 'lesson_id' => $question->lesson_id ?? null, + ]; + } + + $score = ($processedAnswersCount > 0) ? round(($correctCount / $processedAnswersCount) * 100) : 0; + $attempt->score = $score; + $attempt->save(); + }); + } catch (Exception $e) { + Log::error('CourseReviewSubmit: Failed to save attempt.', ['error' => $e->getMessage()]); + + return Redirect::route('course.review.generate', $course) + ->with('error', 'An error occurred saving your review attempt.'); + } + + $lessonsToReviewDeeply = []; + foreach ($resultsData as $result) { + if ($result['is_correct']) { + continue; + } + if (! $result['lesson_id']) { + continue; + } + if (isset($lessonsToReviewDeeply[$result['lesson_id']])) { + continue; + } + $lessonModel = Lesson::with('externalResources')->find($result['lesson_id']); + if ($lessonModel) { + $lessonsToReviewDeeply[$result['lesson_id']] = [ + 'id' => $lessonModel->id, + 'title' => $lessonModel->title, + 'url' => route('lessons.show', ['course' => $course->slug, 'lesson' => $lessonModel->slug]), + 'external_resources' => $lessonModel->externalResources->map(fn ($res) => $res->only('title', 'url', 'type', 'description'))->toArray(), + ]; + } + } + + return Inertia::render('quizzes/Result', [ + 'quiz' => ['id' => $attempt->quiz_id, 'title' => $course->finalReviewQuiz?->title ?? "{$course->title} - Final Review"], + 'attempt' => $attempt, + 'results' => $resultsData, + 'reviewSuggestions' => [], // TODO: Legacy, not used here + 'deepReviewSuggestions' => array_values($lessonsToReviewDeeply), + ]); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 88be480..3f7f02c 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers; +use App\Models\LearningPath; +use Exception; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response; @@ -18,14 +20,41 @@ public function index(): Response return Inertia::render('Dashboard', ['quizAttempts' => []]); } - $quizAttempts = $user->quizAttempts() - ->with('quiz:id,title') - ->latest('completed_at') - ->take(10) - ->get(['id', 'quiz_id', 'score', 'completed_at']); + $learningPaths = LearningPath::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'description']); + + $nextSuggestedCourse = null; + if ($user->learning_path_id && $user->learningPath) { + $pathCourses = $user->learningPath->courses; + + foreach ($pathCourses as $pathCourse) { + $allLessonsInCourse = $pathCourse->modules->pluck('lessons')->flatten(); + $completedLessonsInCourse = $user->progress() + ->whereIn('lesson_id', $allLessonsInCourse->pluck('id')) + ->count(); + + if ($completedLessonsInCourse < $allLessonsInCourse->count() || $allLessonsInCourse->isEmpty()) { + $nextSuggestedCourse = $pathCourse; + break; + } + } + } + + try { + $quizAttempts = $user->quizAttempts() + ->with('quiz:id,title') + ->latest('completed_at') + ->take(10) + ->get(['id', 'quiz_id', 'score', 'completed_at']); + } catch (Exception) { + $quizAttempts = collect(); + } return Inertia::render('Dashboard', [ 'quizAttempts' => $quizAttempts, + 'learningPaths' => $learningPaths, + 'nextSuggestedCourse' => $nextSuggestedCourse ? $nextSuggestedCourse->only('id', 'title', 'slug', 'description') : null, ]); } } diff --git a/app/Http/Controllers/LessonController.php b/app/Http/Controllers/LessonController.php index 531596f..8a4f970 100644 --- a/app/Http/Controllers/LessonController.php +++ b/app/Http/Controllers/LessonController.php @@ -16,11 +16,15 @@ final class LessonController extends Controller */ public function show(Course $course, Lesson $lesson): Response { + + if ($lesson->module->course_id !== $course->id) { + abort(404); + } + if ($lesson->module->course_id !== $course->id) { abort(404); } - // Ensure the parent course is published if (! $course->is_published) { abort(404); } diff --git a/app/Http/Controllers/QuizController.php b/app/Http/Controllers/QuizController.php index 600e4e0..6aeef75 100644 --- a/app/Http/Controllers/QuizController.php +++ b/app/Http/Controllers/QuizController.php @@ -61,7 +61,6 @@ public function show(Quiz $quiz): Response public function submit(Request $request, Quiz $quiz): Response { $user = Auth::user(); - // Expecting answers in format: { 'question_id': 'selected_option_id', ... } $submittedAnswers = $request->input('answers', []); $request->validate([ @@ -93,10 +92,30 @@ public function submit(Request $request, Quiz $quiz): Response } $question = $questions->get($questionId); - if (is_string($userAnswer) && is_string($question->correct_answer)) { - $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower(mb_trim($question->correct_answer)); - } else { - $isCorrect = $userAnswer === $question->correct_answer; + $isCorrect = false; + $correctAnswer = $question->correct_answer; + + switch ($question->type) { + case 'multiple_choice': + if (is_string($userAnswer) && is_string($correctAnswer)) { + $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower(mb_trim($correctAnswer)); + } else { + $isCorrect = $userAnswer === $correctAnswer; + } + break; + case 'true_false': + if (is_string($userAnswer) && is_string($correctAnswer)) { + $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower($correctAnswer); + } + break; + case 'fill_blank': + if (is_string($userAnswer) && is_string($correctAnswer)) { + $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower(mb_trim($correctAnswer)); + } + break; + default: + Log::warning("Grading logic not implemented for question type: {$question->type}"); + break; } QuizAnswer::create([ @@ -114,6 +133,7 @@ public function submit(Request $request, Quiz $quiz): Response $resultsData[] = [ 'question_id' => $question->id, + 'question_type' => $question->type, 'question_text' => $question->text, 'options' => $question->options, 'user_answer' => $userAnswer, diff --git a/app/Http/Controllers/RandomQuizController.php b/app/Http/Controllers/RandomQuizController.php new file mode 100644 index 0000000..748dd0a --- /dev/null +++ b/app/Http/Controllers/RandomQuizController.php @@ -0,0 +1,218 @@ +input('count', self::DEFAULT_QUESTION_COUNT); + + $completedLessonIds = $user->progress()->pluck('lesson_id')->unique(); + + if ($completedLessonIds->isEmpty()) { + return Inertia::render('RandomQuiz/Show', [ + 'quizData' => null, + 'message' => 'You need to complete some lessons before taking a review quiz!', + ]); + } + + $questions = Question::whereIn('lesson_id', $completedLessonIds) + ->select(['id', 'quiz_id', 'lesson_id', 'type', 'text', 'options', 'correct_answer', 'explanation', 'order']) + ->inRandomOrder() + ->take($questionCount) + ->get(); + + if ($questions->isEmpty()) { + return Inertia::render('RandomQuiz/Show', [ + 'quizData' => null, + 'message' => 'No review questions available for the lessons you\'ve completed yet.', + ]); + } + + $displayQuestions = $questions->map(function ($question): array { + $optionsArray = []; + if (! empty($question->options)) { + $optionsArray = is_array($question->options) + ? $question->options + : (json_decode($question->options, true) ?? []); + } + + return [ + 'id' => $question->id, + 'lesson_id' => $question->lesson_id, + 'type' => $question->type, + 'text' => $question->text, + 'options' => $optionsArray, + ]; + }); + + $quizData = [ + 'id' => 'random_'.uniqid(), + 'title' => 'Random Review Quiz', + 'description' => 'Test your knowledge on lessons you\'ve completed.', + 'questions' => $displayQuestions, + 'allQuestionsData' => $questions->keyBy('id')->toArray(), + ]; + + session(['_randomQuizQuestions' => $quizData['allQuestionsData']]); + + return Inertia::render('RandomQuiz/Show', [ + 'quizData' => $quizData, + 'message' => null, + ]); + } + + public function submit(Request $request): Response|RedirectResponse + { + $user = Auth::user(); + $submittedAnswers = $request->input('answers', []); + + $request->validate([ + 'answers' => 'required|array', + 'answers.*' => 'required', + ]); + + $submittedQuestionIds = array_keys($submittedAnswers); + + if ($submittedQuestionIds === []) { + return Redirect::route('random-quiz.generate') + ->with('error', 'No answers were submitted.'); + } + + $questions = Question::whereIn('id', $submittedQuestionIds) + ->select(['id', 'lesson_id', 'options', 'correct_answer', 'explanation', 'text']) + ->get() + ->keyBy('id'); + + $attempt = null; + $resultsData = []; + $correctCount = 0; + $totalQuestions = $questions->count(); + + try { + DB::transaction(function () use ( + $user, $submittedAnswers, $questions, &$attempt, + &$correctCount, &$totalQuestions, &$resultsData + ): void { + $attempt = QuizAttempt::create([ + 'user_id' => $user->id, + 'quiz_id' => null, + 'type' => QuizAttempt::TYPE_RANDOM, + 'started_at' => now(), + 'completed_at' => now(), + ]); + + foreach ($submittedAnswers as $questionId => $userAnswer) { + if (! $questions->has($questionId)) { + Log::warning('RandomQuizSubmit: Grading skipped for unknown/missing question ID.', ['qId' => $questionId]); + + continue; + } + + $question = $questions->get($questionId); // Get the Eloquent model + + $optionsArray = $question->options ?? []; // Rely on model cast + + $isCorrect = false; + $correctAnswer = $question->correct_answer; + + if (is_string($userAnswer) && is_string($correctAnswer)) { + $isCorrect = mb_strtolower(mb_trim($userAnswer)) === mb_strtolower(mb_trim($correctAnswer)); + } elseif (! is_null($correctAnswer)) { + $isCorrect = $userAnswer === $correctAnswer; + } + + if ($isCorrect) { + $correctCount++; + } + + QuizAnswer::create([ + 'quiz_attempt_id' => $attempt->id, + 'question_id' => $question->id, + 'user_answer' => $userAnswer, + 'is_correct' => $isCorrect, + ]); + + $resultsData[] = [ + 'question_id' => $question->id, + 'question_text' => $question->text ?? 'N/A', + 'options' => $optionsArray, + 'user_answer' => $userAnswer, + 'correct_answer' => $correctAnswer ?? 'N/A', + 'is_correct' => $isCorrect, + 'explanation' => $question->explanation ?? '', + 'lesson_id' => $question->lesson_id, + ]; + } + + $processedAnswersCount = count($resultsData); + $score = ($processedAnswersCount > 0) ? round(($correctCount / $processedAnswersCount) * 100) : 0; + $attempt->score = $score; + $attempt->save(); + + }); + + } catch (Exception $e) { + Log::error('RandomQuizSubmit: Failed to save attempt.', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); + + return Redirect::route('random-quiz.generate') + ->with('error', 'An error occurred while saving your quiz attempt. Please try again.'); + } + + if (! $attempt) { + Log::error('RandomQuizSubmit: Attempt object null after transaction.'); + + return Redirect::route('random-quiz.generate') + ->with('error', 'Failed to create quiz attempt record.'); + } + + $lessonIdsToReview = collect($resultsData)->where('is_correct', false)->pluck('lesson_id')->filter()->unique(); + $reviewLessons = Lesson::whereIn('id', $lessonIdsToReview) + ->with(['module.course']) + ->select('id', 'title', 'slug', 'module_id') + ->get(); + + $reviewSuggestions = $reviewLessons->map(function ($lesson): ?array { + $courseSlug = $lesson->module?->course?->slug; + if (! $courseSlug) { + return null; + } + + return [ + 'id' => $lesson->id, + 'title' => $lesson->title, + 'url' => route('lessons.show', ['course' => $courseSlug, 'lesson' => $lesson->slug]), + ]; + })->filter()->values(); + + return Inertia::render('quizzes/Result', [ + 'quiz' => ['id' => null, 'title' => 'Random Review Quiz'], + 'attempt' => $attempt->loadMissing('answers.question'), + 'results' => $resultsData, + 'reviewSuggestions' => $reviewSuggestions, + ]); + } +} diff --git a/app/Http/Controllers/UserPreferenceController.php b/app/Http/Controllers/UserPreferenceController.php new file mode 100644 index 0000000..08d0736 --- /dev/null +++ b/app/Http/Controllers/UserPreferenceController.php @@ -0,0 +1,42 @@ +validate([ + 'preferred_learning_style' => [ + 'nullable', + 'string', + Rule::in(['reading', 'visual']), + ], + 'learning_path_id' => [ + 'nullable', + 'integer', + Rule::exists('learning_paths', 'id')->where(function ($query) { + return $query->where('is_active', true); + }), + ], + ]); + + $user->fill($validated); + $user->save(); + + return Redirect::route('dashboard')->with('success', 'Preferences updated successfully!'); + } +} diff --git a/app/Http/Controllers/UserStatsController.php b/app/Http/Controllers/UserStatsController.php new file mode 100644 index 0000000..77abfc1 --- /dev/null +++ b/app/Http/Controllers/UserStatsController.php @@ -0,0 +1,36 @@ +route('login'); + } + + $totalQuizzesAttempted = $user->getTotalQuizzesAttempted(); + $learningStreak = $user->getLearningStreak(); + $quizScoreDistribution = $user->getQuizScoreDistribution(); + $lessonContributionData = $user->getLessonContributionData(); + + return Inertia::render('stats/Show', [ + 'totalQuizzesAttempted' => $totalQuizzesAttempted, + 'learningStreak' => $learningStreak, + 'quizScoreDistribution' => $quizScoreDistribution, + 'lessonContributionData' => $lessonContributionData, + ]); + } +} diff --git a/app/Models/Course.php b/app/Models/Course.php index 876998c..6830f8c 100644 --- a/app/Models/Course.php +++ b/app/Models/Course.php @@ -7,6 +7,7 @@ use Database\Factories\CourseFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; final class Course extends Model @@ -19,6 +20,8 @@ final class Course extends Model 'slug', 'description', 'is_published', + 'assessment_quiz_id', + 'final_review_quiz_id', ]; protected $casts = [ @@ -27,7 +30,6 @@ final class Course extends Model /** * Use the 'slug' column for route model binding instead of the 'id'. - * Example: Route::get('/courses/{course}', ...) will find by the slug. */ public function getRouteKeyName(): string { @@ -36,10 +38,14 @@ public function getRouteKeyName(): string /** * A Course has many Modules. - * Orders modules by the 'order' column. */ public function modules(): HasMany { return $this->hasMany(Module::class)->orderBy('order'); } + + public function finalReviewQuiz(): BelongsTo + { + return $this->belongsTo(Quiz::class, 'final_review_quiz_id'); + } } diff --git a/app/Models/ExternalResource.php b/app/Models/ExternalResource.php new file mode 100644 index 0000000..518e5ac --- /dev/null +++ b/app/Models/ExternalResource.php @@ -0,0 +1,27 @@ +belongsTo(Lesson::class); + } +} diff --git a/app/Models/LearningPath.php b/app/Models/LearningPath.php new file mode 100644 index 0000000..bbbd216 --- /dev/null +++ b/app/Models/LearningPath.php @@ -0,0 +1,40 @@ + 'boolean', + ]; + + // A learning path can have many users + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + // A learning path has many courses + public function courses(): BelongsToMany + { + return $this->belongsToMany(Course::class, 'learning_path_course') + ->withPivot('order') + ->orderBy('pivot_order', 'asc'); + } +} diff --git a/app/Models/Lesson.php b/app/Models/Lesson.php index 872a07e..4b8f7a4 100644 --- a/app/Models/Lesson.php +++ b/app/Models/Lesson.php @@ -21,6 +21,10 @@ final class Lesson extends Model 'title', 'slug', 'content', + 'video_embed_html', + 'assignment', + 'initial_code', + 'expected_output', 'order', ]; @@ -52,4 +56,10 @@ public function userProgress(): HasMany { return $this->hasMany(UserProgress::class); } + + // A Lesson has many external resources. + public function externalResources(): HasMany + { + return $this->hasMany(ExternalResource::class); + } } diff --git a/app/Models/Module.php b/app/Models/Module.php index a1a54ac..314e9ef 100644 --- a/app/Models/Module.php +++ b/app/Models/Module.php @@ -21,18 +21,13 @@ final class Module extends Model 'order', ]; - /** - * Define the relationship: A Module belongs to one Course. - */ + // A Module belongs to one Course. public function course(): BelongsTo { return $this->belongsTo(Course::class); } - /** - * Define the relationship: A Module has many Lessons. - * Orders lessons by the 'order' column. - */ + // A Module has many Lessons. public function lessons(): HasMany { return $this->hasMany(Lesson::class)->orderBy('order'); diff --git a/app/Models/Question.php b/app/Models/Question.php index 6fc2650..df96c10 100644 --- a/app/Models/Question.php +++ b/app/Models/Question.php @@ -35,7 +35,7 @@ public function quiz(): BelongsTo return $this->belongsTo(Quiz::class); } - // Question tests knowledge from one Lesson (nullable) + // Question tests knowledge from one Lesson public function lesson(): BelongsTo { return $this->belongsTo(Lesson::class); diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php index 5764afd..3720650 100644 --- a/app/Models/Quiz.php +++ b/app/Models/Quiz.php @@ -19,6 +19,7 @@ final class Quiz extends Model protected $fillable = [ 'module_id', 'course_id', + 'type', 'title', 'description', 'order', diff --git a/app/Models/QuizAttempt.php b/app/Models/QuizAttempt.php index d5674b2..6f8d079 100644 --- a/app/Models/QuizAttempt.php +++ b/app/Models/QuizAttempt.php @@ -15,9 +15,16 @@ final class QuizAttempt extends Model /** @use HasFactory */ use HasFactory; + public const string TYPE_STANDARD = 'standard'; + + public const string TYPE_RANDOM = 'random'; + + public const string TYPE_FINAL_REVIEW = 'final_review'; + protected $fillable = [ 'user_id', 'quiz_id', + 'type', 'score', 'started_at', 'completed_at', diff --git a/app/Models/User.php b/app/Models/User.php index 2377ab1..22cb5be 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,9 +4,10 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Carbon\Carbon; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -25,6 +26,8 @@ final class User extends Authenticatable 'name', 'email', 'password', + 'preferred_learning_style', + 'learning_path_id', ]; /** @@ -49,6 +52,120 @@ public function progress(): HasMany return $this->hasMany(UserProgress::class); } + public function learningPath(): BelongsTo + { + return $this->belongsTo(LearningPath::class); + } + + /** + * Get the total number of quizzes this user has attempted. + */ + public function getTotalQuizzesAttempted(): int + { + return $this->quizAttempts()->count(); + } + + /** + * Calculate the user's current learning streak. + */ + public function getLearningStreak(): int + { + $completionDates = $this->progress() + ->selectRaw('DISTINCT DATE(completed_at) as completion_date') + ->orderBy('completion_date', 'desc') + ->pluck('completion_date') + ->map(fn ($date): Carbon => Carbon::parse($date)); + + if ($completionDates->isEmpty()) { + return 0; + } + + $today = Carbon::today(); + $yesterday = Carbon::yesterday(); + + if ($completionDates->first()->isSameDay($today)) { + $streak = 1; + } elseif ($completionDates->first()->isSameDay($yesterday)) { + $streak = 1; + } else { + return 0; + } + + $currentExpectedDate = $completionDates->first()->isSameDay($today) ? $yesterday : $yesterday->copy()->subDay(); + + for ($i = 1; $i < $completionDates->count(); $i++) { + $completedDate = $completionDates[$i]; + if ($completedDate->isSameDay($currentExpectedDate)) { + $streak++; + $currentExpectedDate->subDay(); + } else { + break; + } + } + + return $streak; + } + + public function getQuizScoreDistribution(): array + { + $scores = $this->quizAttempts()->pluck('score')->filter(fn ($score): bool => ! is_null($score)); + + $distribution = [ + 'green' => 0, + 'yellow' => 0, + 'red' => 0, + ]; + + $greenThreshold = 80; + $yellowThreshold = 50; + + foreach ($scores as $score) { + if ($score >= $greenThreshold) { + $distribution['green']++; + } elseif ($score >= $yellowThreshold) { + $distribution['yellow']++; + } else { + $distribution['red']++; + } + } + + return $distribution; + } + + /** + * Get lesson completion data for a contribution graph. + * Returns data for the last year by default. + * Format: [['YYYY-MM-DD', count], ['YYYY-MM-DD', count], ...] + */ + public function getLessonContributionData(int $days = 365): array + { + $startDate = Carbon::today()->subDays($days - 1); // Go back $days-1 to include today + $endDate = Carbon::today(); + + $completionsByDate = $this->progress() + ->selectRaw('DATE(completed_at) as completion_date, COUNT(*) as count') + ->where('completed_at', '>=', $startDate->copy()->startOfDay()) + ->where('completed_at', '<=', $endDate->copy()->endOfDay()) + ->groupBy('completion_date') + ->orderBy('completion_date', 'asc') + ->get() + ->keyBy(fn ($item): string => Carbon::parse($item->completion_date)->toDateString()); + + $completionData = []; + $currentDate = $startDate->copy(); + + while ($currentDate->lte($endDate)) { + $dateString = $currentDate->toDateString(); + $completionData[] = [ + 'date' => $dateString, + 'count' => $completionsByDate->get($dateString)?->count ?? 0, + ]; + $currentDate->addDay(); + } + + return $completionData; + } + /** * Get the attributes that should be cast. * @@ -59,6 +176,7 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'preferred_learning_style' => 'string', ]; } } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 5ef79e4..18fb540 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -14,12 +14,13 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { - $table->id(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); + $table->enum('preferred_learning_style', ['reading', 'visual'])->default('reading')->nullable()->after('password'); + $table->foreignId('learning_path_id')->nullable()->after('preferred_learning_style')->constrained('learning_paths')->onDelete('set null'); $table->timestamps(); }); diff --git a/database/migrations/2025_04_27_192057_create_courses_table.php b/database/migrations/2025_04_27_192057_create_courses_table.php index fb2c2ad..7f6e0f8 100644 --- a/database/migrations/2025_04_27_192057_create_courses_table.php +++ b/database/migrations/2025_04_27_192057_create_courses_table.php @@ -19,6 +19,8 @@ public function up(): void $table->string('slug')->unique(); $table->text('description')->nullable(); $table->boolean('is_published')->default(false); + $table->foreignId('assessment_quiz_id')->nullable()->after('is_published')->constrained('quizzes')->onDelete('set null'); + $table->foreignId('final_review_quiz_id')->nullable()->after('assessment_quiz_id')->constrained('quizzes')->onDelete('set null'); $table->timestamps(); }); } diff --git a/database/migrations/2025_04_27_192057_create_lessons_table.php b/database/migrations/2025_04_27_192057_create_lessons_table.php index b6d0e71..5571829 100644 --- a/database/migrations/2025_04_27_192057_create_lessons_table.php +++ b/database/migrations/2025_04_27_192057_create_lessons_table.php @@ -19,6 +19,10 @@ public function up(): void $table->string('title'); $table->string('slug')->unique(); $table->longText('content')->comment('Lesson text, Markdown or HTML'); + $table->text('video_embed_html')->nullable()->after('content'); + $table->text('assignment')->nullable()->comment('Assignment description'); + $table->text('initial_code')->nullable()->comment('Starting code for editor'); + $table->text('expected_output')->nullable(); $table->unsignedSmallInteger('order')->default(0); $table->timestamps(); diff --git a/database/migrations/2025_04_28_131921_create_quizzes_table.php b/database/migrations/2025_04_28_131921_create_quizzes_table.php index 557d58b..938143b 100644 --- a/database/migrations/2025_04_28_131921_create_quizzes_table.php +++ b/database/migrations/2025_04_28_131921_create_quizzes_table.php @@ -17,6 +17,7 @@ public function up(): void $table->id(); $table->foreignId('module_id')->nullable()->constrained()->onDelete('cascade'); $table->foreignId('course_id')->nullable()->constrained()->onDelete('cascade'); + $table->enum('type', ['assessment', 'module', 'final_review'])->default('module')->after('id'); $table->string('title'); $table->text('description')->nullable(); $table->unsignedSmallInteger('order')->default(0)->comment('Order within module/course'); diff --git a/database/migrations/2025_04_28_131922_create_quiz_answers_table.php b/database/migrations/2025_04_28_131922_create_quiz_answers_table.php index a2df5ad..b749613 100644 --- a/database/migrations/2025_04_28_131922_create_quiz_answers_table.php +++ b/database/migrations/2025_04_28_131922_create_quiz_answers_table.php @@ -18,7 +18,7 @@ public function up(): void $table->foreignId('quiz_attempt_id')->constrained()->onDelete('cascade'); $table->foreignId('question_id')->constrained()->onDelete('cascade'); $table->string('user_answer')->nullable(); - $table->boolean('is_correct')->nullable()->comment('Graded result'); + $table->boolean('is_correct')->nullable(); $table->timestamps(); // User can answer each question once per attempt diff --git a/database/migrations/2025_04_28_131922_create_quiz_attempts_table.php b/database/migrations/2025_04_28_131922_create_quiz_attempts_table.php index 57cbe07..6683832 100644 --- a/database/migrations/2025_04_28_131922_create_quiz_attempts_table.php +++ b/database/migrations/2025_04_28_131922_create_quiz_attempts_table.php @@ -16,7 +16,8 @@ public function up(): void Schema::create('quiz_attempts', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->foreignId('quiz_id')->constrained()->onDelete('cascade'); + $table->foreignId('quiz_id')->nullable()->constrained()->onDelete('cascade'); + $table->string('type')->default('standard')->after('quiz_id'); $table->unsignedTinyInteger('score')->nullable(); $table->timestamp('started_at')->useCurrent(); $table->timestamp('completed_at')->nullable(); diff --git a/database/migrations/2025_05_22_184048_create_learning_paths_table.php b/database/migrations/2025_05_22_184048_create_learning_paths_table.php new file mode 100644 index 0000000..dff2db5 --- /dev/null +++ b/database/migrations/2025_05_22_184048_create_learning_paths_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('learning_paths'); + } +}; diff --git a/database/migrations/2025_05_22_184102_create_learning_path_course_table.php b/database/migrations/2025_05_22_184102_create_learning_path_course_table.php new file mode 100644 index 0000000..b9a5376 --- /dev/null +++ b/database/migrations/2025_05_22_184102_create_learning_path_course_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('learning_path_id')->constrained()->onDelete('cascade'); + $table->foreignId('course_id')->constrained()->onDelete('cascade'); + $table->unsignedSmallInteger('order')->default(0); + $table->timestamps(); + + $table->unique(['learning_path_id', 'course_id']); + $table->index(['learning_path_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('learning_path_course'); + } +}; diff --git a/database/migrations/2025_05_22_184123_create_external_resources_table.php b/database/migrations/2025_05_22_184123_create_external_resources_table.php new file mode 100644 index 0000000..a59449a --- /dev/null +++ b/database/migrations/2025_05_22_184123_create_external_resources_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('lesson_id')->constrained()->onDelete('cascade'); + $table->string('title'); + $table->text('url'); + $table->enum('type', ['video', 'article', 'documentation', 'book_chapter', 'interactive_tool'])->default('article'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('external_resources'); + } +}; diff --git a/database/seeders/CourseSeeder.php b/database/seeders/CourseSeeder.php index 9eae7e3..f958e93 100644 --- a/database/seeders/CourseSeeder.php +++ b/database/seeders/CourseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\Course; +use App\Models\Quiz; use Illuminate\Database\Seeder; final class CourseSeeder extends Seeder @@ -14,18 +15,58 @@ final class CourseSeeder extends Seeder */ public function run(): void { + Quiz::create([ + 'module_id' => null, + 'type' => 'assessment', + 'title' => 'JS Basics Placement Assessment', + 'description' => 'Check your existing JavaScript knowledge to find the best starting point.', + 'order' => 0, + ]); + + Quiz::create([ + 'module_id' => null, + 'type' => 'final_review', + 'title' => 'JS Fundamentals - Final Review', + 'description' => 'Comprehensive review of all JavaScript Fundamentals topics.', + 'order' => 99, + ]); + + Quiz::create([ + 'module_id' => null, + 'type' => 'final_review', + 'title' => 'Intermediate Web Dev - Final Review', + 'description' => 'Comprehensive review of Intermediate Web Development topics.', + 'order' => 99, + ]); + + $assessmentQuiz = Quiz::where('type', 'assessment')->where('title', 'JS Basics Placement Assessment')->first(); + $fundFinalReviewQuiz = Quiz::where('type', 'final_review')->where('title', 'JS Fundamentals - Final Review')->first(); + $intFinalReviewQuiz = Quiz::where('type', 'final_review')->where('title', 'Intermediate Web Dev - Final Review')->first(); + + Course::create([ + 'title' => 'JavaScript Fundamentals', + 'slug' => 'javascript-fundamentals', + 'description' => 'Master the absolute basics of JavaScript, from variables to basic DOM interaction.', + 'is_published' => true, + 'assessment_quiz_id' => $assessmentQuiz?->id, + 'final_review_quiz_id' => $fundFinalReviewQuiz?->id, + ]); + Course::create([ - 'title' => 'JavaScript Basics', - 'slug' => 'javascript-basics', - 'description' => 'Learn the fundamentals of JavaScript.', + 'title' => 'Intermediate Web Development with JS', + 'slug' => 'intermediate-web-dev-js', + 'description' => 'Dive deeper into browser APIs, asynchronous JavaScript, and interacting with web pages.', 'is_published' => true, + 'assessment_quiz_id' => null, + 'final_review_quiz_id' => $intFinalReviewQuiz?->id, ]); Course::create([ - 'title' => 'Advanced JavaScript Concepts', - 'slug' => 'advanced-javascript', - 'description' => 'Dive deeper into JavaScript topics.', - 'is_published' => false, + 'title' => 'Advanced JavaScript & Node.js', + 'slug' => 'advanced-js-nodejs', + 'description' => 'Explore modern JS features, server-side concepts with Node.js, and common patterns.', + 'is_published' => true, + 'assessment_quiz_id' => null, ]); } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 77fcf29..70a0460 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,10 @@ public function run(): void { // User::factory(10)->create(); + $this->call([ + LearningPathSeeder::class, + ]); + User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', @@ -23,8 +27,10 @@ public function run(): void $this->call([ CourseSeeder::class, + LearningPathCourseSeeder::class, ModuleSeeder::class, LessonSeeder::class, + ExternalResourceSeeder::class, QuizSeeder::class, QuestionSeeder::class, ]); diff --git a/database/seeders/LessonSeeder.php b/database/seeders/LessonSeeder.php index 13d9ccc..aab7693 100644 --- a/database/seeders/LessonSeeder.php +++ b/database/seeders/LessonSeeder.php @@ -15,41 +15,56 @@ final class LessonSeeder extends Seeder */ public function run(): void { - $introModule = Module::where('title', 'Introduction')->first(); - $varsModule = Module::where('title', 'Variables and Data Types')->first(); - - if ($introModule) { - Lesson::create([ - 'module_id' => $introModule->id, - 'title' => 'What is JavaScript?', - 'slug' => 'what-is-javascript', - 'content' => '

JavaScript is a scripting language used to create and control dynamic website content...

', - 'order' => 1, - ]); - Lesson::create([ - 'module_id' => $introModule->id, - 'title' => 'Setting up Your Environment', - 'slug' => 'setting-up-environment', - 'content' => '

You can run JavaScript directly in your browser\'s console...

', - 'order' => 2, - ]); + function createLesson($module, $order, $title, $slug, $content, $video_embed_html = null, $assignment = null, $initial_code = null, $expected_output = null) + { + if ($module) { + Lesson::create([ + 'module_id' => $module->id, + 'title' => $order.'. '.$title, + 'slug' => $slug, + 'content' => "

{$content}

", + 'video_embed_html' => $video_embed_html, + 'assignment' => $assignment, + 'initial_code' => $initial_code, + 'expected_output' => $expected_output, + 'order' => $order, + ]); + } } - if ($varsModule) { - Lesson::create([ - 'module_id' => $varsModule->id, - 'title' => 'Declaring Variables (var, let, const)', - 'slug' => 'declaring-variables', - 'content' => '

Variables are containers for storing data values. Use let and const...

', - 'order' => 1, - ]); - Lesson::create([ - 'module_id' => $varsModule->id, - 'title' => 'Primitive Data Types', - 'slug' => 'primitive-data-types', - 'content' => '

JavaScript has several primitive types: String, Number, Boolean, Null, Undefined, Symbol, BigInt...

', - 'order' => 2, - ]); - } + // --- Fundamentals Lessons --- + $modFund1 = Module::where('title', '1. Introduction')->first(); + createLesson($modFund1, 1, 'What is JavaScript?', 'what-is-javascript', 'JavaScript (JS) is a versatile scripting language used primarily for creating dynamic and interactive web content.', '', 'Use `console.log` to print the message "Hello, World!" to the console.', 'console.log("Your message here");', "Hello, World!\n"); + createLesson($modFund1, 2, 'Setting Up Your Environment', 'setting-up-environment', 'You can run JavaScript directly in your browser\'s developer console (usually F12). For larger projects, you\'ll use a code editor and Node.js.'); + createLesson($modFund1, 3, 'Your First Code: console.log', 'first-code-console-log', 'The `console.log()` function is essential for displaying output and debugging your code.', '', 'Use `console.log` to print the message "Hello, World!" to the console.', 'console.log("Your message here");', "Hello, World!\n"); + + $modFund2 = Module::where('title', '2. Variables & Data Types')->first(); + createLesson($modFund2, 1, 'Declaring Variables (var, let, const)', 'declaring-variables', 'Learn how `var`, `let`, and `const` are used to store data. Modern JS prefers `let` and `const`.', null, "Declare a variable `city` using `let` with the value 'Paris'. Log it.", "let city = \nconsole.log(city);", "Paris\n"); + createLesson($modFund2, 2, 'Primitive Data Types', 'primitive-data-types', 'Explore String, Number, Boolean, Null, Undefined, Symbol, and BigInt.', null, 'Declare constants `age` (number 30) and `isStudent` (boolean false). Log their types.', "const age = \nconst isStudent = \nconsole.log(typeof age);\nconsole.log(typeof isStudent);", "number\nboolean\n"); + createLesson($modFund2, 3, 'Type Coercion', 'type-coercion', 'Understand how JavaScript sometimes automatically converts data types, which can lead to unexpected results.', null, 'Log the result of `5 + "5"` to the console. What is the type?', 'console.log(5 + "5");\nconsole.log(typeof (5 + "5"));', "55\nstring\n"); + + // ... Add lessons for Fundamentals Modules 3, 4, 5, 6, 7 with placeholders ... + $modFund3 = Module::where('title', '3. Operators')->first(); + createLesson($modFund3, 1, 'Arithmetic Operators', 'arithmetic-operators', 'Placeholder: +, -, *, /, %, ++, --'); + createLesson($modFund3, 2, 'Comparison Operators', 'comparison-operators', 'Placeholder: ==, ===, !=, !==, >, <, >=, <='); + createLesson($modFund3, 3, 'Logical Operators', 'logical-operators', 'Placeholder: &&, ||, !'); + + $modFund4 = Module::where('title', '4. Control Flow (If/Loops)')->first(); + createLesson($modFund4, 1, 'If / Else Statements', 'if-else', 'Placeholder: Conditional execution.'); + createLesson($modFund4, 2, 'Switch Statements', 'switch', 'Placeholder: Multi-way branching.'); + createLesson($modFund4, 3, 'For Loops', 'for-loops', 'Placeholder: Iterating a set number of times.'); + createLesson($modFund4, 4, 'While Loops', 'while-loops', 'Placeholder: Iterating based on a condition.'); + + // --- Intermediate Lessons --- + $modInt1 = Module::where('title', '1. DOM Deep Dive')->first(); + createLesson($modInt1, 1, 'Selecting Elements (Advanced)', 'dom-selecting-advanced', 'Placeholder: querySelectorAll, getElementsByClassName, etc.'); + createLesson($modInt1, 2, 'Traversing the DOM', 'dom-traversing', 'Placeholder: parentNode, children, nextElementSibling.'); + createLesson($modInt1, 3, 'Creating & Appending Elements', 'dom-creating-appending', 'Placeholder: createElement, appendChild, insertBefore.'); + + // --- Advanced Lessons --- + $modAdv1 = Module::where('title', '1. Async/Await')->first(); + createLesson($modAdv1, 1, 'Async/Await Syntax', 'async-await-syntax', 'Placeholder: Cleaner way to handle Promises.'); + createLesson($modAdv1, 2, 'Error Handling with Async/Await', 'async-await-errors', 'Placeholder: Using try...catch blocks.'); + } } diff --git a/database/seeders/ModuleSeeder.php b/database/seeders/ModuleSeeder.php index b86cf6f..e9d514d 100644 --- a/database/seeders/ModuleSeeder.php +++ b/database/seeders/ModuleSeeder.php @@ -15,24 +15,39 @@ final class ModuleSeeder extends Seeder */ public function run(): void { - $jsBasics = Course::where('slug', 'javascript-basics')->first(); + // --- Fundamentals Course Modules --- + $fundCourse = Course::where('slug', 'javascript-fundamentals')->first(); + if ($fundCourse) { + Module::create(['course_id' => $fundCourse->id, 'title' => '1. Introduction', 'order' => 1]); + Module::create(['course_id' => $fundCourse->id, 'title' => '2. Variables & Data Types', 'order' => 2]); + Module::create(['course_id' => $fundCourse->id, 'title' => '3. Operators', 'order' => 3]); + Module::create(['course_id' => $fundCourse->id, 'title' => '4. Control Flow (If/Loops)', 'order' => 4]); + Module::create(['course_id' => $fundCourse->id, 'title' => '5. Functions', 'order' => 5]); + Module::create(['course_id' => $fundCourse->id, 'title' => '6. Arrays & Objects Intro', 'order' => 6]); + Module::create(['course_id' => $fundCourse->id, 'title' => '7. Basic DOM Manipulation', 'order' => 7]); + } + + // --- Intermediate Course Modules --- + $intCourse = Course::where('slug', 'intermediate-web-dev-js')->first(); + if ($intCourse) { + Module::create(['course_id' => $intCourse->id, 'title' => '1. DOM Deep Dive', 'order' => 1]); + Module::create(['course_id' => $intCourse->id, 'title' => '2. Events In-Depth', 'order' => 2]); + Module::create(['course_id' => $intCourse->id, 'title' => '3. Working with Forms', 'order' => 3]); + Module::create(['course_id' => $intCourse->id, 'title' => '4. Asynchronous JavaScript & Promises', 'order' => 4]); + Module::create(['course_id' => $intCourse->id, 'title' => '5. Fetch API & AJAX', 'order' => 5]); + Module::create(['course_id' => $intCourse->id, 'title' => '6. Browser Storage', 'order' => 6]); + } - if ($jsBasics) { - Module::create([ - 'course_id' => $jsBasics->id, - 'title' => 'Introduction', - 'order' => 1, - ]); - Module::create([ - 'course_id' => $jsBasics->id, - 'title' => 'Variables and Data Types', - 'order' => 2, - ]); - Module::create([ - 'course_id' => $jsBasics->id, - 'title' => 'Operators', - 'order' => 3, - ]); + // --- Advanced Course Modules --- + $advCourse = Course::where('slug', 'advanced-js-nodejs')->first(); + if ($advCourse) { + Module::create(['course_id' => $advCourse->id, 'title' => '1. Async/Await', 'order' => 1]); + Module::create(['course_id' => $advCourse->id, 'title' => '2. JavaScript Modules (ESM/CJS)', 'order' => 2]); + Module::create(['course_id' => $advCourse->id, 'title' => '3. OOP in JavaScript (Classes)', 'order' => 3]); + Module::create(['course_id' => $advCourse->id, 'title' => '4. Functional Programming Patterns', 'order' => 4]); + Module::create(['course_id' => $advCourse->id, 'title' => '5. Advanced Error Handling', 'order' => 5]); + Module::create(['course_id' => $advCourse->id, 'title' => '6. Introduction to Node.js', 'order' => 6]); + Module::create(['course_id' => $advCourse->id, 'title' => '7. Building a Simple API with Node', 'order' => 7]); } } } diff --git a/database/seeders/QuestionSeeder.php b/database/seeders/QuestionSeeder.php index 570d3f0..12a5701 100644 --- a/database/seeders/QuestionSeeder.php +++ b/database/seeders/QuestionSeeder.php @@ -18,6 +18,7 @@ public function run(): void { $introQuiz = Quiz::where('title', 'Quiz: Introduction Concepts')->first(); $varsQuiz = Quiz::where('title', 'Quiz: Variables')->first(); + $assessmentQuiz = Quiz::where('type', 'assessment')->where('title', 'JS Basics Placement Assessment')->first(); $lessonWhatIsJs = Lesson::where('slug', 'what-is-javascript')->first(); $lessonSetup = Lesson::where('slug', 'setting-up-environment')->first(); @@ -57,6 +58,16 @@ public function run(): void 'order' => 2, ]); + Question::create([ + 'quiz_id' => $introQuiz->id, + 'lesson_id' => $lessonWhatIsJs?->id, + 'type' => 'true_false', + 'text' => 'JavaScript can only be used on the frontend (in the browser).', + 'options' => null, + 'correct_answer' => 'false', + 'explanation' => 'JavaScript can also be used on the backend with Node.js.', + 'order' => 3, + ]); } if ($varsQuiz) { @@ -90,6 +101,73 @@ public function run(): void 'explanation' => 'Arrays are objects in JavaScript, not primitive types.', 'order' => 2, ]); + + Question::create([ + 'quiz_id' => $varsQuiz->id, + 'lesson_id' => $lessonDeclare?->id, + 'type' => 'fill_blank', + 'text' => 'The keyword ____ declares a variable that cannot be reassigned.', + 'options' => null, + 'correct_answer' => 'const', + 'explanation' => '`const` ensures the variable binding cannot be reassigned.', + 'order' => 3, + ]); + Question::create([ + 'quiz_id' => $varsQuiz->id, + 'lesson_id' => $lessonTypes?->id, + 'type' => 'true_false', + 'text' => '`null` and `undefined` represent the same absence of value in JavaScript.', + 'options' => null, + 'correct_answer' => 'false', + 'explanation' => 'They both represent absence, but `null` is an assigned "no value", while `undefined` means a variable hasn\'t been assigned a value.', + 'order' => 4, + ]); + } + + if ($assessmentQuiz && $lessonWhatIsJs && $lessonDeclare && $lessonTypes) { + Question::create([ + 'quiz_id' => $assessmentQuiz->id, + 'lesson_id' => $lessonWhatIsJs?->id, + 'type' => 'multiple_choice', + 'text' => 'What is JavaScript primarily used for?', + 'options' => json_encode([ + ['id' => 'a', 'text' => 'Styling web pages'], + ['id' => 'b', 'text' => 'Creating dynamic web content'], + ['id' => 'c', 'text' => 'Managing databases'], + ['id' => 'd', 'text' => 'Server-side logic only'], + ]), + 'correct_answer' => 'b', + 'explanation' => 'JavaScript runs in the browser to make web pages interactive.', + 'order' => 1, + ]); + Question::create([ + 'quiz_id' => $assessmentQuiz->id, + 'lesson_id' => $lessonDeclare->id, + 'type' => 'multiple_choice', + 'text' => 'Which keyword prevents a variable from being reassigned?', + 'options' => json_encode([['id' => 'a', 'text' => 'let'], ['id' => 'b', 'text' => 'var'], ['id' => 'c', 'text' => 'const']]), + 'correct_answer' => 'c', + 'order' => 1, + ]); + Question::create([ + 'quiz_id' => $assessmentQuiz->id, + 'lesson_id' => $lessonTypes->id, + 'type' => 'true_false', + 'text' => 'Is `null` considered an object type by the `typeof` operator in JavaScript?', + 'options' => null, + 'correct_answer' => 'true', + 'explanation' => 'Due to a historical bug, `typeof null` returns "object".', + 'order' => 2, + ]); + Question::create([ + 'quiz_id' => $assessmentQuiz->id, + 'lesson_id' => $lessonTypes->id, + 'type' => 'fill_blank', + 'text' => 'The data type for textual data is called a _____.', + 'options' => null, + 'correct_answer' => 'string', + 'order' => 3, + ]); } } } diff --git a/database/seeders/QuizSeeder.php b/database/seeders/QuizSeeder.php index f7c6463..eccde34 100644 --- a/database/seeders/QuizSeeder.php +++ b/database/seeders/QuizSeeder.php @@ -16,8 +16,8 @@ final class QuizSeeder extends Seeder */ public function run(): void { - $introModule = Module::where('title', 'Introduction')->first(); - $varsModule = Module::where('title', 'Variables and Data Types')->first(); + $introModule = Module::where('title', '1. Introduction')->first(); + $varsModule = Module::where('title', '2. Variables & Data Types')->first(); if ($introModule) { $introQuiz = Quiz::create([ diff --git a/package-lock.json b/package-lock.json index a7f186c..513eb78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@inertiajs/vue3": "^2.0.0-beta.3", + "@monaco-editor/loader": "^1.5.0", "@tailwindcss/vite": "^4.1.1", "@vitejs/plugin-vue": "^5.2.1", "@vueuse/core": "^12.8.2", @@ -15,6 +16,7 @@ "laravel-vite-plugin": "^1.0", "lucide": "^0.468.0", "lucide-vue-next": "^0.468.0", + "monaco-editor": "^0.52.2", "reka-ui": "^2.2.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", @@ -22,6 +24,7 @@ "typescript": "^5.2.2", "vite": "^6.2.0", "vue": "^3.5.13", + "vue-chartjs": "^5.3.2", "ziggy-js": "^2.4.2" }, "devDependencies": { @@ -829,6 +832,22 @@ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "license": "MIT" }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT", + "peer": true + }, + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2043,6 +2062,19 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3579,6 +3611,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4237,6 +4275,12 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4535,6 +4579,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz", + "integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", diff --git a/package.json b/package.json index 362fdc9..fcf6f61 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@inertiajs/vue3": "^2.0.0-beta.3", + "@monaco-editor/loader": "^1.5.0", "@tailwindcss/vite": "^4.1.1", "@vitejs/plugin-vue": "^5.2.1", "@vueuse/core": "^12.8.2", @@ -33,6 +34,7 @@ "laravel-vite-plugin": "^1.0", "lucide": "^0.468.0", "lucide-vue-next": "^0.468.0", + "monaco-editor": "^0.52.2", "reka-ui": "^2.2.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", @@ -40,6 +42,7 @@ "typescript": "^5.2.2", "vite": "^6.2.0", "vue": "^3.5.13", + "vue-chartjs": "^5.3.2", "ziggy-js": "^2.4.2" }, "optionalDependencies": { diff --git a/resources/css/app.css b/resources/css/app.css index 0e71966..5a3e3a7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,6 @@ @import 'tailwindcss'; -@import "tw-animate-css"; +@import 'tw-animate-css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @@ -8,56 +8,54 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-sans: - Instrument Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-sans: Instrument Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); + --color-background: var(--background); + --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar-background); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); } /* @@ -69,98 +67,94 @@ color utility to any element that depends on these defaults. */ @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } } @layer utilities { - body, - html { - --font-sans: - 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; - } + body, + html { + --font-sans: + 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + } } :root { - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 92.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 92.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); - --radius: 0.5rem; - --sidebar-background: hsl(0 0% 98%); - --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(0 0% 10%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 94%); - --sidebar-accent-foreground: hsl(0 0% 30%); - --sidebar-border: hsl(0 0% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: - hsl(0 0% 98%); + --background: hsl(0 0% 100%); + --foreground: hsl(0 0% 3.9%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(0 0% 3.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(0 0% 3.9%); + --primary: hsl(0 0% 9%); + --primary-foreground: hsl(0 0% 98%); + --secondary: hsl(0 0% 92.1%); + --secondary-foreground: hsl(0 0% 9%); + --muted: hsl(0 0% 96.1%); + --muted-foreground: hsl(0 0% 45.1%); + --accent: hsl(0 0% 96.1%); + --accent-foreground: hsl(0 0% 9%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(0 0% 92.8%); + --input: hsl(0 0% 89.8%); + --ring: hsl(0 0% 3.9%); + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --radius: 0.5rem; + --sidebar-background: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(0 0% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(0 0% 94%); + --sidebar-accent-foreground: hsl(0 0% 30%); + --sidebar-border: hsl(0 0% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + --sidebar: hsl(0 0% 98%); } .dark { - --background: hsl(0 0% 3.9%); - --foreground: hsl(0 0% 98%); - --card: hsl(0 0% 3.9%); - --card-foreground: hsl(0 0% 98%); - --popover: hsl(0 0% 3.9%); - --popover-foreground: 0 0% 98%; - --primary: hsl(0 0% 98%); - --primary-foreground: hsl(0 0% 9%); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 16.08%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0 0% 14.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 84% 60%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 14.9%); - --input: hsl(0 0% 14.9%); - --ring: hsl(0 0% 83.1%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 7%); - --sidebar-foreground: hsl(0 0% 95.9%); - --sidebar-primary: hsl(360, 100%, 100%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(0 0% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(0 0% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: - hsl(240 5.9% 10%); + --background: hsl(0 0% 3.9%); + --foreground: hsl(0 0% 98%); + --card: hsl(0 0% 3.9%); + --card-foreground: hsl(0 0% 98%); + --popover: hsl(0 0% 3.9%); + --popover-foreground: 0 0% 98%; + --primary: hsl(0 0% 98%); + --primary-foreground: hsl(0 0% 9%); + --secondary: hsl(0 0% 14.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(0 0% 16.08%); + --muted-foreground: hsl(0 0% 63.9%); + --accent: hsl(0 0% 14.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 84% 60%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(0 0% 14.9%); + --input: hsl(0 0% 14.9%); + --ring: hsl(0 0% 83.1%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar-background: hsl(0 0% 7%); + --sidebar-foreground: hsl(0 0% 95.9%); + --sidebar-primary: hsl(360, 100%, 100%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(0 0% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(0 0% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + --sidebar: hsl(240 5.9% 10%); } @layer base { @@ -178,10 +172,10 @@ */ @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 84902b2..161dfc7 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -62,7 +62,7 @@ const rightNavItems: NavItem[] = [