first commit

This commit is contained in:
2026-01-23 17:28:21 +07:00
commit 29ff8992b9
331 changed files with 30545 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
use App\Services\SocialAuthService;
class AuthenticatedSessionController extends Controller
{
public function __construct(
protected SocialAuthService $socialAuthService
) {}
/**
* Display the login view.
*/
public function create(): Response
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
$user = Auth::user();
// Revoke Google tokens if present
if ($user) {
$user->socialAccounts()->where('provider', 'google')->get()->each(function ($account) {
$this->socialAuthService->revokeToken($account);
});
}
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): Response
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|Response
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): Response
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): Response
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): Response
{
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\SocialAccount;
use Exception;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Illuminate\Support\Str;
class SocialAuthController extends Controller
{
/**
* Redirect to the provider's authentication page.
*/
public function redirectToProvider($provider)
{
return Socialite::driver($provider)->redirect();
}
/**
* Obtain the user information from the provider.
*/
public function handleProviderCallback($provider)
{
try {
$socialUser = Socialite::driver($provider)->user();
} catch (Exception $e) {
return redirect()->route('login')->with('error', 'Authentication failed.');
}
$user = $this->findOrCreateUser($socialUser, $provider);
Auth::login($user, true);
return redirect()->intended(route('dashboard'));
}
/**
* Find or create a user based on social account information.
*/
protected function findOrCreateUser($socialUser, $provider)
{
$account = SocialAccount::where('provider', $provider)
->where('provider_id', $socialUser->getId())
->first();
if ($account) {
// Update tokens
$account->update([
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
]);
return $account->user;
}
// Check if user with same email exists
$user = User::where('email', $socialUser->getEmail())->first();
if (!$user) {
// Create a new user
$user = User::create([
'name' => $socialUser->getName() ?? $socialUser->getNickname() ?? 'User',
'email' => $socialUser->getEmail(),
'avatar_url' => $socialUser->getAvatar(),
'password' => null, // Social users don't need a local password initially
]);
// Assign default role
$user->assignRole('student');
}
// Link social account
SocialAccount::create([
'user_id' => $user->id,
'provider' => $provider,
'provider_id' => $socialUser->getId(),
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
]);
return $user;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\Level;
use App\Models\Enrollment;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CourseLibraryController extends Controller
{
/**
* Display the course library.
*/
public function index(): Response
{
$levels = Level::with(['courses' => function($query) {
$query->withCount('modules');
}])->get();
$enrolledCourseIds = Enrollment::where('user_id', auth()->id())
->pluck('course_id')
->toArray();
return Inertia::render('Courses/Library', [
'levels' => $levels->map(function($level) use ($enrolledCourseIds) {
return [
'id' => $level->id,
'name' => $level->name,
'code' => $level->code,
'courses' => $level->courses->map(function($course) use ($enrolledCourseIds) {
return [
'id' => $course->id,
'title' => $course->title,
'description' => $course->description,
'thumbnail' => $course->thumbnail_url,
'slug' => $course->slug,
'modulesCount' => $course->modules_count,
'isEnrolled' => in_array($course->id, $enrolledCourseIds),
];
})
];
})
]);
}
/**
* Enroll in a course.
*/
public function enroll(Request $request, Course $course)
{
$user = auth()->user();
// Check if already enrolled
$exists = Enrollment::where('user_id', $user->id)
->where('course_id', $course->id)
->exists();
if (!$exists) {
Enrollment::create([
'user_id' => $user->id,
'course_id' => $course->id,
'status' => 'active',
'enrolled_at' => now(),
]);
}
return redirect()->route('courses.learn', ['course' => $course->slug])
->with('status', "Berhasil mendaftar di kursus: {$course->title}");
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\Lesson;
use App\Models\Enrollment;
use App\Models\UserProgress;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CoursePlayerController extends Controller
{
/**
* Display the course player.
*/
public function show(Request $request, string $courseSlug, string $lessonSlug = null): Response
{
$user = $request->user();
$course = Course::where('slug', $courseSlug)
->with(['modules.lessons'])
->firstOrFail();
// Check enrollment
$isEnrolled = Enrollment::where('user_id', $user->id)
->where('course_id', $course->id)
->exists();
if (!$isEnrolled) {
return Inertia::render('Errors/Unauthorized', [
'message' => 'Anda belum terdaftar di kursus ini.'
]);
}
// Get all lessons for navigation and progress check
$allLessons = $course->modules->flatMap->lessons;
// Find current lesson
$currentLesson = $lessonSlug
? Lesson::where('slug', $lessonSlug)->firstOrFail()
: $allLessons->first();
// Get user progress for this course
$completedLessonsIds = UserProgress::where('user_id', $user->id)
->whereIn('lesson_id', $allLessons->pluck('id'))
->pluck('lesson_id')
->toArray();
return Inertia::render('Courses/Player', [
'course' => [
'id' => $course->id,
'title' => $course->title,
'slug' => $course->slug,
'modules' => $course->modules->map(function ($module) use ($completedLessonsIds) {
return [
'id' => $module->id,
'title' => $module->title,
'lessons' => $module->lessons->map(function ($lesson) use ($completedLessonsIds) {
return [
'id' => $lesson->id,
'title' => $lesson->title,
'slug' => $lesson->slug,
'type' => $lesson->type,
'is_completed' => in_array($lesson->id, $completedLessonsIds),
];
})
];
})
],
'currentLesson' => [
'id' => $currentLesson->id,
'title' => $currentLesson->title,
'slug' => $currentLesson->slug,
'type' => $currentLesson->type,
'content' => $currentLesson->content,
'video_url' => $currentLesson->video_url,
'content_pdf' => $currentLesson->content_pdf,
],
'progress' => [
'completed_count' => count($completedLessonsIds),
'total_count' => count($allLessons),
'percentage' => count($allLessons) > 0 ? round((count($completedLessonsIds) / count($allLessons)) * 100) : 0,
]
]);
}
/**
* Mark a lesson as completed.
*/
public function complete(Request $request, Lesson $lesson)
{
$user = $request->user();
UserProgress::updateOrCreate(
['user_id' => $user->id, 'lesson_id' => $lesson->id],
['completed_at' => now()]
);
// Optional: Add XP points logic here later
return back()->with('success', 'Materi selesai!');
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\Enrollment;
use Illuminate\Http\Request;
use App\Services\SrsService;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
protected $srsService;
public function __construct(SrsService $srsService)
{
$this->srsService = $srsService;
}
/**
* Display the student dashboard.
*/
public function index(Request $request): Response
{
$user = $request->user();
// Fetch enrolled courses with their progress
$enrolledCourses = Enrollment::where('user_id', $user->id)
->with(['course' => function($query) {
$query->withCount('modules');
$query->with(['level']);
}])
->get()
->map(function ($enrollment) use ($user) {
$course = $enrollment->course;
// Calculate progress (this logic will be refined in the future)
// For now, we'll try to find any completed lessons in this course
$totalLessons = \App\Models\Lesson::whereIn('module_id', $course->modules->pluck('id'))->count();
$completedLessonsCount = \App\Models\UserProgress::where('user_id', $user->id)
->whereIn('lesson_id', function($query) use ($course) {
$query->select('id')
->from('lessons')
->whereIn('module_id', $course->modules->pluck('id'));
})
->count();
$progress = $totalLessons > 0 ? round(($completedLessonsCount / $totalLessons) * 100) : 0;
return [
'id' => $course->id,
'title' => $course->title,
'thumbnail' => $course->thumbnail_url,
'level' => $course->level->code ?? 'Basic',
'progress' => $progress,
'lessonsCount' => $totalLessons,
'completedLessons' => $completedLessonsCount,
'slug' => $course->slug,
];
});
// Fetch Real SRS Stats
$dueCount = $this->srsService->getDueReviews($user, 1000)->count();
$newCount = $this->srsService->getNewCards($user, 1000)->count();
return Inertia::render('Dashboard', [
'stats' => [
'xp_points' => $user->xp_points ?? 0,
'current_streak' => $user->current_streak ?? 0,
'active_courses' => $enrolledCourses->count(),
'certificates' => 0,
'srs_due' => $dueCount,
'srs_new' => $newCount,
],
'activeCourses' => $enrolledCourses,
'user' => [
'name' => $user->name,
'avatar' => $user->avatar_url,
'rank' => 'Genin',
'xp_points' => $user->xp_points ?? 0,
]
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): Response
{
return Inertia::render('Profile/Edit', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => session('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Models\Vocabulary;
use App\Models\SrsReview;
use App\Services\SrsService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SrsController extends Controller
{
protected $srsService;
public function __construct(SrsService $srsService)
{
$this->srsService = $srsService;
}
public function index()
{
$user = auth()->user();
$dueCount = $this->srsService->getDueReviews($user, 1000)->count();
$newCount = $this->srsService->getNewCards($user, 1000)->count();
return Inertia::render('Srs/Index', [
'stats' => [
'due' => $dueCount,
'new' => $newCount,
'total_learned' => SrsReview::where('user_id', $user->id)->count()
]
]);
}
public function practice()
{
$user = auth()->user();
$reviews = $this->srsService->getDueReviews($user, 20);
// If Reviews < 10, fill with New Cards
$newCards = $reviews->count() < 10
? $this->srsService->getNewCards($user, 10 - $reviews->count())
: collect([]);
// Normalize items for frontend
$items = $reviews->toBase()->map(function($review) {
return [
'type' => 'review',
'id' => $review->vocabulary->id,
'word' => $review->vocabulary->word,
'reading' => $review->vocabulary->reading,
'meaning' => $review->vocabulary->meaning_en,
'audio_url' => $review->vocabulary->audio_url,
'srs_id' => $review->id
];
})->merge($newCards->toBase()->map(function($vocab) {
return [
'type' => 'new',
'id' => $vocab->id,
'word' => $vocab->word,
'reading' => $vocab->reading,
'meaning' => $vocab->meaning_en,
'audio_url' => $vocab->audio_url,
];
}));
return Inertia::render('Srs/Practice', [
'items' => $items
]);
}
public function store(Request $request)
{
$request->validate([
'vocabulary_id' => 'required|exists:vocabularies,id',
'grade' => 'required|integer|min:1|max:4' // 1=Again, 2=Hard, 3=Good, 4=Easy
]);
$user = auth()->user();
$vocab = Vocabulary::find($request->vocabulary_id);
$this->srsService->processReview($user, $vocab, $request->grade);
if ($request->wantsJson()) {
return response()->json(['status' => 'success']);
}
return back();
}
}