Skip to content

Commit 82e1718

Browse files
authored
Merge pull request #3 from DimGiagias/feature/add-user-progress
feature/add user progress
2 parents fa7889b + 6dbd684 commit 82e1718

10 files changed

Lines changed: 245 additions & 27 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers;
6+
7+
use Illuminate\Support\Facades\Auth;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
final class DashboardController extends Controller
12+
{
13+
public function index(): Response
14+
{
15+
$user = Auth::user();
16+
17+
if (! $user) {
18+
return Inertia::render('Dashboard', ['quizAttempts' => []]);
19+
}
20+
21+
$quizAttempts = $user->quizAttempts()
22+
->with('quiz:id,title')
23+
->latest('completed_at')
24+
->take(10)
25+
->get(['id', 'quiz_id', 'score', 'completed_at']);
26+
27+
return Inertia::render('Dashboard', [
28+
'quizAttempts' => $quizAttempts,
29+
]);
30+
}
31+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers;
6+
7+
use App\Models\Lesson;
8+
use App\Models\UserProgress;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Illuminate\Support\Facades\Auth;
12+
use Illuminate\Support\Facades\Redirect;
13+
14+
final class UserProgressController extends Controller
15+
{
16+
/**
17+
* Mark the given lesson as completed for the authenticated user.
18+
* Route model binding automatically finds the Lesson by its ID (default key).
19+
*/
20+
public function store(Request $request, Lesson $lesson): RedirectResponse
21+
{
22+
$user = Auth::user();
23+
24+
UserProgress::firstOrCreate(
25+
[
26+
'user_id' => $user->id,
27+
'lesson_id' => $lesson->id,
28+
],
29+
[
30+
'completed_at' => now(),
31+
]
32+
);
33+
34+
return Redirect::back()->with('success', 'Lesson marked as complete!');
35+
}
36+
}

app/Models/Lesson.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Database\Eloquent\Model;
1010
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1111
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
12+
use Illuminate\Database\Eloquent\Relations\HasMany;
1213

1314
final class Lesson extends Model
1415
{
@@ -23,6 +24,9 @@ final class Lesson extends Model
2324
'order',
2425
];
2526

27+
// Add 'is_completed' attribute dynamically for a logged-in user
28+
protected $appends = ['is_completed'];
29+
2630
/**
2731
* Use the 'slug' column for route model binding.
2832
*/
@@ -31,9 +35,7 @@ public function getRouteKeyName(): string
3135
return 'slug';
3236
}
3337

34-
/**
35-
* Define the relationship: A Lesson belongs to one Module.
36-
*/
38+
// A Lesson belongs to one Module.
3739
public function module(): BelongsTo
3840
{
3941
return $this->belongsTo(Module::class);
@@ -44,4 +46,10 @@ public function quizzes(): BelongsToMany
4446
{
4547
return $this->belongsToMany(Quiz::class, 'lesson_quiz');
4648
}
49+
50+
// A Lesson has many progress records
51+
public function userProgress(): HasMany
52+
{
53+
return $this->hasMany(UserProgress::class);
54+
}
4755
}

app/Models/User.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ public function quizAttempts(): HasMany
4343
return $this->hasMany(QuizAttempt::class);
4444
}
4545

46+
// User has many progress records.
47+
public function progress(): HasMany
48+
{
49+
return $this->hasMany(UserProgress::class);
50+
}
51+
4652
/**
4753
* Get the attributes that should be cast.
4854
*

app/Models/UserProgress.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Models;
6+
7+
use Illuminate\Database\Eloquent\Factories\HasFactory;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
11+
final class UserProgress extends Model
12+
{
13+
use HasFactory;
14+
15+
protected $table = 'user_progress';
16+
17+
protected $fillable = [
18+
'user_id',
19+
'lesson_id',
20+
'completed_at',
21+
];
22+
23+
protected $casts = [
24+
'completed_at' => 'datetime',
25+
];
26+
27+
// Progress entry belongs to a User
28+
public function user(): BelongsTo
29+
{
30+
return $this->belongsTo(User::class);
31+
}
32+
33+
// Progress entry belongs to a Lesson
34+
public function lesson(): BelongsTo
35+
{
36+
return $this->belongsTo(Lesson::class);
37+
}
38+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\Schema;
8+
9+
return new class extends Migration
10+
{
11+
/**
12+
* Run the migrations.
13+
*/
14+
public function up(): void
15+
{
16+
Schema::create('user_progress', function (Blueprint $table) {
17+
$table->id();
18+
$table->foreignId('user_id')->constrained()->onDelete('cascade');
19+
$table->foreignId('lesson_id')->constrained()->onDelete('cascade');
20+
$table->timestamp('completed_at')->useCurrent();
21+
$table->timestamps();
22+
23+
// A user can complete a specific lesson only once.
24+
$table->unique(['user_id', 'lesson_id']);
25+
});
26+
}
27+
28+
/**
29+
* Reverse the migrations.
30+
*/
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('user_progress');
34+
}
35+
};

resources/js/pages/Dashboard.vue

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,65 @@
22
import AppLayout from '@/layouts/AppLayout.vue';
33
import { type BreadcrumbItem } from '@/types';
44
import { Head } from '@inertiajs/vue3';
5-
import PlaceholderPattern from '../components/PlaceholderPattern.vue';
65
76
const breadcrumbs: BreadcrumbItem[] = [
87
{
98
title: 'Dashboard',
109
href: '/dashboard',
1110
},
1211
];
12+
13+
defineProps({
14+
quizAttempts: Array,
15+
});
16+
17+
const formatDate = (dateString) => {
18+
if (!dateString) return 'N/A';
19+
return new Date(dateString).toLocaleString();
20+
}
21+
1322
</script>
1423

1524
<template>
1625
<Head title="Dashboard" />
1726

1827
<AppLayout :breadcrumbs="breadcrumbs">
19-
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
20-
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
21-
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
22-
<PlaceholderPattern />
23-
</div>
24-
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
25-
<PlaceholderPattern />
28+
<div class="py-12">
29+
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
30+
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
31+
<div class="p-6 text-gray-900 dark:text-gray-100">You're logged in!</div>
2632
</div>
27-
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
28-
<PlaceholderPattern />
33+
34+
<!-- Quiz History -->
35+
<div class="mt-8 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
36+
<div class="p-6 text-gray-900 dark:text-gray-100">
37+
<h3 class="text-xl font-semibold mb-4">Recent Quiz Attempts</h3>
38+
<div v-if="quizAttempts && quizAttempts.length > 0">
39+
<ul class="space-y-3">
40+
<li v-for="attempt in quizAttempts" :key="attempt.id" class="flex justify-between items-center border-b pb-2">
41+
<div>
42+
<span class="font-medium">{{ attempt.quiz?.title ?? 'Quiz Deleted' }}</span>
43+
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">
44+
on {{ formatDate(attempt.completed_at) }}
45+
</span>
46+
</div>
47+
<span class="text-lg font-semibold"
48+
:class="{
49+
'text-green-600 dark:text-green-400': attempt.score >= 80,
50+
'text-yellow-600 dark:text-yellow-400': attempt.score >= 50 && attempt.score < 80,
51+
'text-red-600 dark:text-red-400': attempt.score < 50
52+
}">
53+
{{ attempt.score ?? 'N/A' }}%
54+
</span>
55+
</li>
56+
</ul>
57+
</div>
58+
<div v-else>
59+
<p>You haven't attempted any quizzes yet.</p>
60+
</div>
61+
</div>
2962
</div>
30-
</div>
31-
<div class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 dark:border-sidebar-border md:min-h-min">
32-
<PlaceholderPattern />
63+
3364
</div>
3465
</div>
3566
</AppLayout>

resources/js/pages/courses/Show.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ defineProps({
3131
<Link
3232
:href="route('lessons.show', { course: course.slug, lesson: lesson.slug })"
3333
class="text-blue-600 hover:underline dark:text-blue-400"
34+
:class="{ 'font-semibold text-gray-800 dark:text-gray-200': lesson.is_completed }"
3435
>
36+
<!-- Add a checkmark if completed -->
37+
<span v-if="lesson.is_completed" class="text-green-500 mr-1">✓</span>
3538
{{ lesson.title }}
3639
</Link>
3740
</li>

resources/js/pages/lessons/Show.vue

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
<script setup lang="ts">
2-
import { Head, Link } from '@inertiajs/vue3';
2+
import { Head, Link, usePage } from '@inertiajs/vue3';
3+
import { computed } from 'vue';
34
45
defineProps({
56
lesson: Object,
67
course: Object,
78
});
89
10+
const page = usePage();
11+
const successMessage = computed(() => page.props.flash?.success);
12+
913
</script>
1014

1115
<template>
1216
<Head :title="lesson.title" />
1317

18+
<div v-if="successMessage" class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded">
19+
{{ successMessage }}
20+
</div>
21+
1422
<div class="py-12">
1523
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
1624
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -27,6 +35,27 @@ defineProps({
2735

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

38+
<div class="mt-8 border-t pt-6">
39+
<!-- Show completed message -->
40+
<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">
41+
<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>
42+
<span>Lesson Completed!</span>
43+
</div>
44+
<!-- Show button if not completed -->
45+
<Link v-else
46+
:href="route('lessons.complete', { lesson: lesson.id })"
47+
method="post"
48+
as="button"
49+
type="button"
50+
preserve-scroll
51+
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"
52+
>
53+
Mark as Complete
54+
</Link>
55+
</div>
56+
</div>
57+
</div>
58+
3059
<div class="mt-8 border-t pt-4">
3160
<button disabled class="bg-gray-400 text-white font-bold py-2 px-4 rounded opacity-50 cursor-not-allowed">
3261
Mark as Complete (Coming Soon)
@@ -49,10 +78,7 @@ defineProps({
4978
</div>
5079
</div>
5180
</div>
52-
5381
</div>
54-
</div>
55-
</div>
5682
</template>
5783

5884
<style scoped>

routes/web.php

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,32 @@
33
declare(strict_types=1);
44

55
use App\Http\Controllers\CourseController;
6+
use App\Http\Controllers\DashboardController;
67
use App\Http\Controllers\LessonController;
78
use App\Http\Controllers\QuizController;
9+
use App\Http\Controllers\UserProgressController;
810
use Illuminate\Support\Facades\Route;
911
use Inertia\Inertia;
1012

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

15-
Route::get('dashboard', function () {
16-
return Inertia::render('Dashboard');
17-
})->middleware(['auth', 'verified'])->name('dashboard');
17+
Route::middleware(['auth', 'verified'])->group(function (): void {
18+
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
19+
20+
Route::get('/quizzes/{quiz}', [QuizController::class, 'show'])->name('quizzes.show');
21+
Route::post('/quizzes/{quiz}/submit', [QuizController::class, 'submit'])->name('quizzes.submit');
22+
23+
Route::post('/lessons/{lesson}/complete', [UserProgressController::class, 'store'])->name('lessons.complete');
24+
25+
});
1826

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

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

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

25-
Route::get('/quizzes/{quiz}', [QuizController::class, 'show'])->middleware(['auth', 'verified'])->name('quizzes.show');
26-
27-
Route::post('/quizzes/{quiz}/submit', [QuizController::class, 'submit'])->middleware(['auth', 'verified'])->name('quizzes.submit');
28-
2933
require __DIR__.'/settings.php';
3034
require __DIR__.'/auth.php';

0 commit comments

Comments
 (0)