Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/Http/Controllers/DashboardController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;

final class DashboardController extends Controller
{
public function index(): Response
{
$user = Auth::user();

if (! $user) {
return Inertia::render('Dashboard', ['quizAttempts' => []]);
}

$quizAttempts = $user->quizAttempts()
->with('quiz:id,title')
->latest('completed_at')
->take(10)
->get(['id', 'quiz_id', 'score', 'completed_at']);

return Inertia::render('Dashboard', [
'quizAttempts' => $quizAttempts,
]);
}
}
36 changes: 36 additions & 0 deletions app/Http/Controllers/UserProgressController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Lesson;
use App\Models\UserProgress;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;

final class UserProgressController extends Controller
{
/**
* Mark the given lesson as completed for the authenticated user.
* Route model binding automatically finds the Lesson by its ID (default key).
*/
public function store(Request $request, Lesson $lesson): RedirectResponse
{
$user = Auth::user();

UserProgress::firstOrCreate(
[
'user_id' => $user->id,
'lesson_id' => $lesson->id,
],
[
'completed_at' => now(),
]
);

return Redirect::back()->with('success', 'Lesson marked as complete!');
}
}
14 changes: 11 additions & 3 deletions app/Models/Lesson.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

final class Lesson extends Model
{
Expand All @@ -23,6 +24,9 @@ final class Lesson extends Model
'order',
];

// Add 'is_completed' attribute dynamically for a logged-in user
protected $appends = ['is_completed'];

/**
* Use the 'slug' column for route model binding.
*/
Expand All @@ -31,9 +35,7 @@ public function getRouteKeyName(): string
return 'slug';
}

/**
* Define the relationship: A Lesson belongs to one Module.
*/
// A Lesson belongs to one Module.
public function module(): BelongsTo
{
return $this->belongsTo(Module::class);
Expand All @@ -44,4 +46,10 @@ public function quizzes(): BelongsToMany
{
return $this->belongsToMany(Quiz::class, 'lesson_quiz');
}

// A Lesson has many progress records
public function userProgress(): HasMany
{
return $this->hasMany(UserProgress::class);
}
}
6 changes: 6 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public function quizAttempts(): HasMany
return $this->hasMany(QuizAttempt::class);
}

// User has many progress records.
public function progress(): HasMany
{
return $this->hasMany(UserProgress::class);
}

/**
* Get the attributes that should be cast.
*
Expand Down
38 changes: 38 additions & 0 deletions app/Models/UserProgress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

final class UserProgress extends Model
{
use HasFactory;

protected $table = 'user_progress';

protected $fillable = [
'user_id',
'lesson_id',
'completed_at',
];

protected $casts = [
'completed_at' => 'datetime',
];

// Progress entry belongs to a User
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

// Progress entry belongs to a Lesson
public function lesson(): BelongsTo
{
return $this->belongsTo(Lesson::class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_progress', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('lesson_id')->constrained()->onDelete('cascade');
$table->timestamp('completed_at')->useCurrent();
$table->timestamps();

// A user can complete a specific lesson only once.
$table->unique(['user_id', 'lesson_id']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_progress');
}
};
57 changes: 44 additions & 13 deletions resources/js/pages/Dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,65 @@
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '../components/PlaceholderPattern.vue';

const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: '/dashboard',
},
];

defineProps({
quizAttempts: Array,
});

const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}

</script>

<template>
<Head title="Dashboard" />

<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern />
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">You're logged in!</div>
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern />

<!-- Quiz History -->
<div class="mt-8 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-xl font-semibold mb-4">Recent Quiz Attempts</h3>
<div v-if="quizAttempts && quizAttempts.length > 0">
<ul class="space-y-3">
<li v-for="attempt in quizAttempts" :key="attempt.id" class="flex justify-between items-center border-b pb-2">
<div>
<span class="font-medium">{{ attempt.quiz?.title ?? 'Quiz Deleted' }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">
on {{ formatDate(attempt.completed_at) }}
</span>
</div>
<span class="text-lg font-semibold"
:class="{
'text-green-600 dark:text-green-400': attempt.score >= 80,
'text-yellow-600 dark:text-yellow-400': attempt.score >= 50 && attempt.score < 80,
'text-red-600 dark:text-red-400': attempt.score < 50
}">
{{ attempt.score ?? 'N/A' }}%
</span>
</li>
</ul>
</div>
<div v-else>
<p>You haven't attempted any quizzes yet.</p>
</div>
</div>
</div>
</div>
<div class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 dark:border-sidebar-border md:min-h-min">
<PlaceholderPattern />

</div>
</div>
</AppLayout>
Expand Down
3 changes: 3 additions & 0 deletions resources/js/pages/courses/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ defineProps({
<Link
:href="route('lessons.show', { course: course.slug, lesson: lesson.slug })"
class="text-blue-600 hover:underline dark:text-blue-400"
:class="{ 'font-semibold text-gray-800 dark:text-gray-200': lesson.is_completed }"
>
<!-- Add a checkmark if completed -->
<span v-if="lesson.is_completed" class="text-green-500 mr-1">✓</span>
{{ lesson.title }}
</Link>
</li>
Expand Down
34 changes: 30 additions & 4 deletions resources/js/pages/lessons/Show.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head, Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';

defineProps({
lesson: Object,
course: Object,
});

const page = usePage();
const successMessage = computed(() => page.props.flash?.success);

</script>

<template>
<Head :title="lesson.title" />

<div v-if="successMessage" class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded">
{{ successMessage }}
</div>

<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
Expand All @@ -27,6 +35,27 @@ defineProps({

<div class="prose dark:prose-invert max-w-none" v-html="lesson.content"></div>

<div class="mt-8 border-t pt-6">
<!-- Show completed message -->
<div v-if="lesson.is_completed" class="flex items-center p-3 bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-md text-green-700 dark:text-green-300 font-semibold">
<svg class="w-6 h-6 mr-2 fill-current" viewBox="0 0 20 20"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"></path></svg>
<span>Lesson Completed!</span>
</div>
<!-- Show button if not completed -->
<Link v-else
:href="route('lessons.complete', { lesson: lesson.id })"
method="post"
as="button"
type="button"
preserve-scroll
class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-700 active:bg-blue-800 focus:outline-none focus:border-blue-900 focus:ring ring-blue-300 disabled:opacity-25 transition ease-in-out duration-150"
>
Mark as Complete
</Link>
</div>
</div>
</div>

<div class="mt-8 border-t pt-4">
<button disabled class="bg-gray-400 text-white font-bold py-2 px-4 rounded opacity-50 cursor-not-allowed">
Mark as Complete (Coming Soon)
Expand All @@ -49,10 +78,7 @@ defineProps({
</div>
</div>
</div>

</div>
</div>
</div>
</template>

<style scoped>
Expand Down
18 changes: 11 additions & 7 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,32 @@
declare(strict_types=1);

use App\Http\Controllers\CourseController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\LessonController;
use App\Http\Controllers\QuizController;
use App\Http\Controllers\UserProgressController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/', function () {
return Inertia::render('Welcome');
})->name('home');

Route::get('dashboard', function () {
return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware(['auth', 'verified'])->group(function (): void {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');

Route::get('/quizzes/{quiz}', [QuizController::class, 'show'])->name('quizzes.show');
Route::post('/quizzes/{quiz}/submit', [QuizController::class, 'submit'])->name('quizzes.submit');

Route::post('/lessons/{lesson}/complete', [UserProgressController::class, 'store'])->name('lessons.complete');

});

Route::get('/courses', [CourseController::class, 'index'])->name('courses.index');

Route::get('/courses/{course}', [CourseController::class, 'show'])->name('courses.show');

Route::get('/courses/{course}/lessons/{lesson}', [LessonController::class, 'show'])->name('lessons.show');

Route::get('/quizzes/{quiz}', [QuizController::class, 'show'])->middleware(['auth', 'verified'])->name('quizzes.show');

Route::post('/quizzes/{quiz}/submit', [QuizController::class, 'submit'])->middleware(['auth', 'verified'])->name('quizzes.submit');

require __DIR__.'/settings.php';
require __DIR__.'/auth.php';