Initial commit
This commit is contained in:
commit
c909d539bc
7178 files changed
+756624
No files matched your search
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use App\Jobs\SyncNextcloudBusyJob;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
/**
|
||||
* Define the application's command schedule.
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// Run every minute, inline (no queue needed)
|
||||
$schedule->call(function () {
|
||||
SyncNextcloudBusyJob::dispatchSync(); // or (new SyncNextcloudBusyJob)->handle();
|
||||
})->everyMinute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the commands for the application.
|
||||
*/
|
||||
protected function commands(): void
|
||||
{
|
||||
$this->load(__DIR__.'/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\BillingAccount;
|
||||
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function checkEmail(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$billing = BillingAccount::where('invoice_email', $request->email)->first();
|
||||
|
||||
if ($billing) {
|
||||
return response()->json([
|
||||
'exists' => true,
|
||||
'billing_account_id' => $billing->id,
|
||||
'billing_name' => $billing->name,
|
||||
'primary_parent_user_id' => $billing->primary_parent_user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'exists' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Slot;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class BookingController extends Controller
|
||||
{
|
||||
public function create(Request $request)
|
||||
{
|
||||
// Validate query params: /admin/bookings/create?date=YYYY-MM-DD&slot_id=123
|
||||
$data = $request->validate([
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
]);
|
||||
|
||||
$slot = Slot::findOrFail($data['slot_id']);
|
||||
|
||||
// Build nice labels (Europe/London) so Blade doesn't need Carbon
|
||||
$when = Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$prettyDate = $when->format('D d M Y');
|
||||
$prettyTime = $when->format('H:i');
|
||||
$weekdayName = $when->format('l');
|
||||
|
||||
return view('bookings.create', [
|
||||
'slot' => $slot,
|
||||
'date' => $data['date'],
|
||||
'prettyDate' => $prettyDate,
|
||||
'prettyTime' => $prettyTime,
|
||||
'weekdayName' => $weekdayName,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Needed at top of file (ensure these use's exist):
|
||||
// use App\Models\Slot;
|
||||
// use App\Models\Booking;
|
||||
// use Carbon\Carbon;
|
||||
// use Illuminate\Validation\ValidationException;
|
||||
// use Illuminate\Database\QueryException;
|
||||
|
||||
$data = $request->validate([
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'student_name' => ['required','string','max:255'],
|
||||
'booked_from_page' => ['nullable','string','max:255'],
|
||||
'message' => ['nullable','string'],
|
||||
]);
|
||||
|
||||
// Enforce > 2 hours' notice (exactly 120 mins = NOT allowed)
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
$sessionStart = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
|
||||
if ($minutesUntil <= 120) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'date' => 'Bookings require more than 2 hours’ notice.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Persist booking; default status for admin-created bookings = 'booked'
|
||||
$data['status'] = 'booked';
|
||||
|
||||
try {
|
||||
\App\Models\Booking::create($data);
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Handle unique (slot_id, date) violation gracefully
|
||||
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'slot_id' => 'That slot has just been taken. Please choose another.',
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.calendar')
|
||||
->with('success', 'Booking created successfully.');
|
||||
}
|
||||
|
||||
public function createBlock(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Needed at top of file:
|
||||
// use App\Models\Slot;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
$data = $request->validate([
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
]);
|
||||
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
|
||||
// Prettify for the view (no Carbon in Blade)
|
||||
$when = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$prettyDate = $when->format('D d M Y');
|
||||
$prettyTime = $when->format('H:i');
|
||||
$weekdayName = $when->format('l');
|
||||
|
||||
return view('bookings.block', [
|
||||
'slot' => $slot,
|
||||
'date' => $data['date'],
|
||||
'prettyDate' => $prettyDate,
|
||||
'prettyTime' => $prettyTime,
|
||||
'weekdayName' => $weekdayName,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeBlock(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Validation: block a specific slot on a specific date
|
||||
$data = $request->validate([
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'message' => ['nullable','string'], // optional reason/notes for the block
|
||||
]);
|
||||
|
||||
// Create a "blocked" booking for that slot/date
|
||||
try {
|
||||
\App\Models\Booking::create([
|
||||
'slot_id' => (int)$data['slot_id'],
|
||||
'date' => $data['date'],
|
||||
'student_name' => null, // blocks don't need a student
|
||||
'booked_from_page' => 'admin:block', // provenance
|
||||
'message' => $data['message'] ?? null,
|
||||
'status' => 'blocked',
|
||||
]);
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Unique (slot_id, date) hit — something already exists for that slot/date
|
||||
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
|
||||
return back()
|
||||
->withErrors(['slot_id' => 'That slot/date already has a booking or block.'])
|
||||
->withInput();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.calendar')
|
||||
->with('success', 'Slot blocked successfully.');
|
||||
}
|
||||
|
||||
public function createSeries(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Needed at top of file (ensure these exist):
|
||||
// use App\Models\Slot;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
// Accept prefill from calendar click: ?date=YYYY-MM-DD&slot_id=NN&weeks=6
|
||||
$data = $request->validate([
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'weeks' => ['nullable','integer','min:1','max:52'],
|
||||
]);
|
||||
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
|
||||
// Precompute nice labels (keep Blade Carbon-free)
|
||||
$when = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$prettyDate = $when->format('D d M Y');
|
||||
$prettyTime = $when->format('H:i');
|
||||
$weekdayName = $when->format('l');
|
||||
|
||||
$weeks = $data['weeks'] ?? 6; // sensible default
|
||||
|
||||
return view('bookings.series', [
|
||||
'slot' => $slot,
|
||||
'date' => $data['date'],
|
||||
'prettyDate' => $prettyDate,
|
||||
'prettyTime' => $prettyTime,
|
||||
'weekdayName' => $weekdayName,
|
||||
'weeks' => $weeks,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeSeries(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Needed at top of file:
|
||||
// use App\Models\Slot;
|
||||
// use App\Models\Booking;
|
||||
// use Carbon\Carbon;
|
||||
// use Illuminate\Database\QueryException;
|
||||
|
||||
$data = $request->validate([
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'date' => ['required','date_format:Y-m-d'], // start date
|
||||
'weeks' => ['required','integer','min:1','max:52'],
|
||||
]);
|
||||
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$startDate = \Carbon\Carbon::createFromFormat('Y-m-d', $data['date'], 'Europe/London');
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
for ($i = 0; $i < (int)$data['weeks']; $i++) {
|
||||
$date = $startDate->copy()->addWeeks($i)->toDateString();
|
||||
$sessionStart = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$slot->time}", 'Europe/London');
|
||||
|
||||
// Skip past or ≤ 2 hours from now
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
if ($minutesUntil <= 120) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
\App\Models\Booking::create([
|
||||
'slot_id' => $slot->id,
|
||||
'date' => $date,
|
||||
'student_name' => null, // series = no student name
|
||||
'booked_from_page' => 'admin:block-series', // provenance
|
||||
'message' => null,
|
||||
'status' => 'booked', // shows as “Booked” on the calendar
|
||||
]);
|
||||
$created++;
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Skip duplicates (unique (slot_id, date) might already exist as booked/closed)
|
||||
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to the week containing the start date (nice UX)
|
||||
$displayStart = $now->copy()->startOfWeek(\Carbon\Carbon::MONDAY);
|
||||
$fridayCutoff = $displayStart->copy()->addDays(4)->setTime(15, 0);
|
||||
if ($now->greaterThanOrEqualTo($fridayCutoff)) {
|
||||
$displayStart->addWeek();
|
||||
}
|
||||
$weekOffset = $displayStart->diffInWeeks($startDate->copy()->startOfWeek(\Carbon\Carbon::MONDAY), false);
|
||||
|
||||
return redirect()
|
||||
->route('admin.calendar', ['week' => $weekOffset ?: null])
|
||||
->with('success', "Series created: {$created} added, {$skipped} skipped.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// app/Http/Controllers/Admin/CalendarController.php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CalendarService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CalendarController extends Controller
|
||||
{
|
||||
public function index(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Make sure you also have at the top of the file:
|
||||
// use App\Models\Slot;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
|
||||
// Default week, flip to next after Fri 15:00
|
||||
$start = $now->copy()->startOfWeek(\Carbon\Carbon::MONDAY);
|
||||
$fridayCutoff = $start->copy()->addDays(4)->setTime(15, 0);
|
||||
if ($now->greaterThanOrEqualTo($fridayCutoff)) {
|
||||
$start->addWeek();
|
||||
}
|
||||
|
||||
// Week navigation
|
||||
$weekOffset = (int) $request->query('week', 0);
|
||||
if ($weekOffset !== 0) {
|
||||
$start->addWeeks($weekOffset);
|
||||
}
|
||||
$end = $start->copy()->addDays(6);
|
||||
|
||||
// 👉 Load slots with bookings for the visible week
|
||||
$slots = \App\Models\Slot::with(['bookings' => function ($q) use ($start, $end) {
|
||||
$q->whereBetween('date', [$start->toDateString(), $end->toDateString()]);
|
||||
}])->get();
|
||||
|
||||
// 👉 Pass $slots (and the other vars) to the view
|
||||
return view('calendar.index', compact('start', 'end', 'weekOffset', 'slots', 'now'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Course;
|
||||
use App\Models\Chapter;
|
||||
|
||||
class ChapterController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'lesson_section_id' => 'required|exists:lesson_sections,id',
|
||||
'title' => 'required|max:255',
|
||||
]);
|
||||
|
||||
// Find the current max order for this section
|
||||
$maxOrder = Chapter::where('lesson_section_id', $request->lesson_section_id)->max('order');
|
||||
|
||||
// If no chapters yet, maxOrder will be null → start at 1
|
||||
$nextOrder = $maxOrder ? $maxOrder + 1 : 1;
|
||||
|
||||
Chapter::create([
|
||||
'lesson_section_id' => $request->lesson_section_id,
|
||||
'title' => $request->title,
|
||||
'order' => $nextOrder,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Chapter added.');
|
||||
}
|
||||
|
||||
public function destroy(Chapter $chapter)
|
||||
{
|
||||
$chapter->delete();
|
||||
return back()->with('success', 'Chapter removed.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'order' => 'required|string',
|
||||
]);
|
||||
|
||||
$ids = json_decode($request->order, true);
|
||||
|
||||
if (is_array($ids)) {
|
||||
foreach ($ids as $index => $id) {
|
||||
Chapter::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Chapter order updated.');
|
||||
}
|
||||
|
||||
public function show(Course $course, Chapter $chapter)
|
||||
{
|
||||
// (Optional) safety check: make sure chapter belongs to this course
|
||||
// via its module / section chain, if you want to enforce that later.
|
||||
return view('admin.chapters.show', [
|
||||
'course' => $course,
|
||||
'chapter' => $chapter,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Course;
|
||||
use App\Models\Subject;
|
||||
use App\Models\Level;
|
||||
|
||||
class CourseController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$courses = Course::all();
|
||||
return view('admin.courses.index', compact('courses'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$subjects = \App\Models\Subject::all();
|
||||
$levels = \App\Models\Level::all();
|
||||
|
||||
return view('admin.courses.create', compact('subjects', 'levels'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'subject_id' => 'required|exists:subjects,id',
|
||||
'level_id' => 'required|exists:levels,id',
|
||||
'lead_teacher' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
Course::create([
|
||||
'subject_id' => $request->input('subject_id'),
|
||||
'level_id' => $request->input('level_id'),
|
||||
'lead_teacher' => $request->input('lead_teacher'),
|
||||
'description' => $request->input('description'),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.courses.index')
|
||||
->with('success', 'Course added successfully!');
|
||||
}
|
||||
|
||||
public function show(Course $course)
|
||||
{
|
||||
// Eager load related modules/sections/chapters so they’re available
|
||||
$course->load([
|
||||
'modules.lessonSections.chapters'
|
||||
]);
|
||||
|
||||
return view('admin.courses.show', compact('course'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the admin dashboard.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// You can pass data here later (e.g., user stats, recent activity)
|
||||
return view('admin.dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\Course;
|
||||
use App\Models\Lesson;
|
||||
use App\Models\LessonVideo;
|
||||
use App\Models\Chapter;
|
||||
|
||||
class LessonController extends Controller
|
||||
{
|
||||
public function reorder(Request $request)
|
||||
{
|
||||
$ids = json_decode($request->order, true);
|
||||
|
||||
if (!is_array($ids)) {
|
||||
return back()->with('error', 'Invalid order data.');
|
||||
}
|
||||
|
||||
foreach ($ids as $index => $id) {
|
||||
Lesson::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Lesson order updated.');
|
||||
}
|
||||
|
||||
public function show($courseId, $chapterId, Lesson $lesson)
|
||||
{
|
||||
// later we’ll validate that the lesson belongs to this chapter & course
|
||||
return view('admin.lessons.show', compact('lesson', 'chapterId', 'courseId'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'chapter_id' => 'required|exists:chapters,id',
|
||||
'title' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$order = Lesson::where('chapter_id', $request->chapter_id)->max('order') + 1;
|
||||
|
||||
Lesson::create([
|
||||
'chapter_id' => $request->chapter_id,
|
||||
'title' => $request->title,
|
||||
'slug' => Str::slug($request->title),
|
||||
'description' => $request->description ?? null,
|
||||
'order' => $order,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Lesson added.');
|
||||
}
|
||||
|
||||
public function destroy(Course $course, Chapter $chapter, Lesson $lesson)
|
||||
{
|
||||
$lesson->delete();
|
||||
|
||||
return redirect()->route('admin.chapters.show', [
|
||||
'course' => $course->slug,
|
||||
'chapter' => $chapter->slug,
|
||||
])->with('success', 'Lesson deleted.');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Course;
|
||||
use App\Models\LessonSection;
|
||||
|
||||
class LessonSectionController extends Controller
|
||||
{
|
||||
|
||||
public function show(Course $course, LessonSection $section)
|
||||
{
|
||||
// Optional safety: ensure this section actually belongs to this course
|
||||
// (assuming LessonSection -> module -> course)
|
||||
if ($section->module && $section->module->course_id !== $course->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Eager load chapters for this section
|
||||
$section->load(['chapters' => function ($q) {
|
||||
$q->orderBy('order');
|
||||
}]);
|
||||
|
||||
return view('admin.sections.show', [
|
||||
'course' => $course,
|
||||
'section' => $section,
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Course $course)
|
||||
{
|
||||
$sections = $course->lessonSections()->orderBy('order')->get();
|
||||
|
||||
return view('admin.lesson_sections.index', compact('course', 'sections'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Course $course)
|
||||
{
|
||||
$request->validate([
|
||||
'module_id' => 'required|exists:modules,id',
|
||||
'title' => 'required|string|max:255',
|
||||
'year' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$module = \App\Models\Module::findOrFail($request->module_id);
|
||||
|
||||
// Safety check: ensure module belongs to this course
|
||||
if ($module->course_id !== $course->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
LessonSection::create([
|
||||
'module_id' => $module->id,
|
||||
'title' => $request->title,
|
||||
'year' => $request->year,
|
||||
'order' => $module->lessonSections()->count() + 1,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Lesson section added.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Course $course, LessonSection $lessonSection)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'year' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$lessonSection->update([
|
||||
'title' => $request->title,
|
||||
'year' => $request->year,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Lesson section updated.');
|
||||
}
|
||||
|
||||
public function destroy(Course $course, LessonSection $section)
|
||||
{
|
||||
// Ensure the section belongs to the course via the module
|
||||
if ($section->module->course_id !== $course->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Delete all chapters and their lessons/videos
|
||||
foreach ($section->chapters as $chapter) {
|
||||
// delete lessons inside the chapter
|
||||
foreach ($chapter->lessons as $lesson) {
|
||||
$lesson->videos()->delete();
|
||||
}
|
||||
|
||||
$chapter->lessons()->delete();
|
||||
$chapter->delete();
|
||||
}
|
||||
|
||||
// Delete the section itself
|
||||
$section->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.courses.show', $course->slug)
|
||||
->with('success', 'Lesson section deleted.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request, Course $course)
|
||||
{
|
||||
$ids = json_decode($request->order, true);
|
||||
|
||||
foreach ($ids as $i => $id) {
|
||||
LessonSection::where('id', $id)->update(['order' => $i + 1]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Lesson section order updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\LessonVideo;
|
||||
|
||||
class LessonVideoController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'lesson_id' => 'required|exists:lessons,id',
|
||||
'title' => 'required|max:255',
|
||||
'video_url' => 'required|url',
|
||||
]);
|
||||
|
||||
$maxOrder = LessonVideo::where('lesson_id', $request->lesson_id)->max('order');
|
||||
$nextOrder = $maxOrder ? $maxOrder + 1 : 1;
|
||||
|
||||
LessonVideo::create([
|
||||
'lesson_id' => $request->lesson_id,
|
||||
'title' => $request->title,
|
||||
'video_url' => $request->video_url,
|
||||
'order' => $nextOrder,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Video added.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'order' => 'required|string',
|
||||
]);
|
||||
|
||||
$ids = json_decode($request->order, true);
|
||||
|
||||
if (is_array($ids)) {
|
||||
foreach ($ids as $index => $id) {
|
||||
LessonVideo::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Video order updated.');
|
||||
}
|
||||
|
||||
public function destroy(LessonVideo $video)
|
||||
{
|
||||
$video->delete();
|
||||
return back()->with('success', 'Video removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Course;
|
||||
use App\Models\Module;
|
||||
|
||||
class ModuleController extends Controller
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
$courses = Course::with(['subject', 'level'])->get();
|
||||
return view('admin.modules.create', compact('courses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'course_id' => 'required|exists:courses,id',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$course = Course::findOrFail($request->course_id);
|
||||
|
||||
$course->modules()->create([
|
||||
'name' => $request->name,
|
||||
'order' => $course->modules()->count() + 1,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.courses.show', $course->slug)
|
||||
->with('success', 'Module added.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request)
|
||||
{
|
||||
$ids = json_decode($request->order, true);
|
||||
|
||||
foreach ($ids as $index => $id) {
|
||||
Module::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Module order updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ParentProfile;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ParentController extends Controller
|
||||
{
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$q = $request->string('q')->toString();
|
||||
|
||||
$parents = ParentProfile::query()
|
||||
->with('user')
|
||||
->when($q, function ($query) use ($q) {
|
||||
$query->whereHas('user', function ($u) use ($q) {
|
||||
$u->where('first_name', 'like', "%{$q}%")
|
||||
->orWhere('last_name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.parents.index', compact('parents', 'q'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$parents = ParentProfile::with('user')->get();
|
||||
$studentTypes = StudentType::all();
|
||||
|
||||
return view('admin.students.create', compact('parents', 'studentTypes'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
ParentProfile::create($validated);
|
||||
|
||||
return redirect()->route('admin.parents.index')
|
||||
->with('success', 'Parent created.');
|
||||
}
|
||||
|
||||
public function edit(ParentProfile $parent)
|
||||
{
|
||||
return view('admin.parents.edit', compact('parent'));
|
||||
}
|
||||
|
||||
public function update(Request $request, ParentProfile $parent)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$parent->update($validated);
|
||||
|
||||
return redirect()->route('admin.parents.edit', $parent)
|
||||
->with('success', 'Parent updated.');
|
||||
}
|
||||
|
||||
public function destroy(ParentProfile $parent)
|
||||
{
|
||||
$parent->delete();
|
||||
|
||||
return redirect()->route('admin.parents.index')
|
||||
->with('success', 'Parent deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\StudentType;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use App\Models\StudentProfile;
|
||||
use App\Models\BillingAccount;
|
||||
use App\Models\ParentProfile;
|
||||
|
||||
class StudentController extends Controller
|
||||
{
|
||||
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
$students = StudentProfile::with(['user', 'billingAccount'])->get();
|
||||
|
||||
return view('admin.students.index', compact('students'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$parents = ParentProfile::with('user')->get();
|
||||
$studentTypes = StudentType::all();
|
||||
|
||||
return view('admin.students.create', compact('parents', 'studentTypes'));
|
||||
}
|
||||
|
||||
public function edit(StudentProfile $student)
|
||||
{
|
||||
$student->load(['user', 'billingAccount', 'billingAccount.primaryParent']);
|
||||
|
||||
$parents = ParentProfile::with('user')->get();
|
||||
$studentTypes = StudentType::all();
|
||||
|
||||
return view('admin.students.edit', compact('student', 'parents', 'studentTypes'));
|
||||
}
|
||||
|
||||
public function update(Request $request, StudentProfile $student)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Student
|
||||
'student_first_name' => 'required|string|max:255',
|
||||
'student_last_name' => 'required|string|max:255',
|
||||
'student_phone' => 'nullable|string|max:255',
|
||||
// If you allow changing student email:
|
||||
// 'student_email' => ['required','email', Rule::unique('users','email')->ignore($student->user_id)],
|
||||
|
||||
'date_of_birth' => 'nullable|date',
|
||||
'student_type_id' => 'required|exists:student_types,id',
|
||||
|
||||
// Parent selection
|
||||
'parent_option' => 'required|in:none,existing,new',
|
||||
'existing_parent_id' => 'required_if:parent_option,existing|nullable|exists:parent_profiles,id',
|
||||
|
||||
// New parent (only used if parent_option=new)
|
||||
'parent_first_name' => 'required_if:parent_option,new|nullable|string|max:255',
|
||||
'parent_last_name' => 'required_if:parent_option,new|nullable|string|max:255',
|
||||
'parent_email' => 'required_if:parent_option,new|nullable|email|unique:users,email',
|
||||
'parent_phone' => 'nullable|string|max:255',
|
||||
|
||||
// Billing (match your edit form; keep minimal initially)
|
||||
'billing_account_id' => 'sometimes|nullable|exists:billing_accounts,id',
|
||||
'billing_name' => 'required|string|max:255',
|
||||
'invoice_email' => 'required|email',
|
||||
'billing_phone' => 'nullable|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'address_line1' => 'required|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'town' => 'required|string|max:255',
|
||||
'postcode' => 'required|string|max:50',
|
||||
'country' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
// Load student user
|
||||
$student->load('user');
|
||||
|
||||
// 1) Update STUDENT USER
|
||||
$student->user->update([
|
||||
'first_name' => $validated['student_first_name'],
|
||||
'last_name' => $validated['student_last_name'],
|
||||
'phone' => $validated['student_phone'] ?? null,
|
||||
// 'email' => $validated['student_email'],
|
||||
]);
|
||||
|
||||
// 2) Update STUDENT PROFILE
|
||||
$student->update([
|
||||
'date_of_birth' => $validated['date_of_birth'] ?? null,
|
||||
'student_type_id' => $validated['student_type_id'],
|
||||
// billing_account_id handled below
|
||||
]);
|
||||
|
||||
// 3) Resolve parent user based on parent_option
|
||||
$parentProfile = null;
|
||||
|
||||
if ($validated['parent_option'] === 'existing') {
|
||||
$parentProfile = ParentProfile::with('user')->find($validated['existing_parent_id']);
|
||||
}
|
||||
|
||||
if ($validated['parent_option'] === 'new') {
|
||||
$parentUser = User::create([
|
||||
'first_name' => $validated['parent_first_name'],
|
||||
'last_name' => $validated['parent_last_name'],
|
||||
'email' => $validated['parent_email'],
|
||||
'phone' => $validated['parent_phone'],
|
||||
'password' => Hash::make(str()->random(16)),
|
||||
]);
|
||||
|
||||
$parentUser->assignRole('parent');
|
||||
|
||||
$parentProfile = ParentProfile::firstOrCreate(
|
||||
['user_id' => $parentUser->id],
|
||||
['phone' => $parentUser->phone]
|
||||
);
|
||||
|
||||
// ensure $parentProfile has user loaded for later
|
||||
$parentProfile->load('user');
|
||||
}
|
||||
|
||||
if ($parentProfile && $parentProfile->user) {
|
||||
// ensure role even for the "existing" case
|
||||
$parentProfile->user->assignRole('parent');
|
||||
}
|
||||
|
||||
// 4) Billing account: update existing or create new (same logic as store)
|
||||
if (!empty($validated['billing_account_id'])) {
|
||||
$billingAccount = BillingAccount::findOrFail($validated['billing_account_id']);
|
||||
$billingAccount->update([
|
||||
'name' => $validated['billing_name'],
|
||||
'contact_name' => $validated['contact_name'],
|
||||
'invoice_email' => $validated['invoice_email'],
|
||||
'phone' => $validated['billing_phone'],
|
||||
'address_line1' => $validated['address_line1'],
|
||||
'address_line2' => $validated['address_line2'],
|
||||
'town' => $validated['town'],
|
||||
'postcode' => $validated['postcode'],
|
||||
'country' => $validated['country'],
|
||||
'primary_parent_user_id' => $parentProfile?->user_id,
|
||||
]);
|
||||
} else {
|
||||
$billingAccount = BillingAccount::create([
|
||||
'name' => $validated['billing_name'],
|
||||
'contact_name' => $validated['contact_name'],
|
||||
'invoice_email' => $validated['invoice_email'],
|
||||
'phone' => $validated['billing_phone'],
|
||||
'address_line1' => $validated['address_line1'],
|
||||
'address_line2' => $validated['address_line2'],
|
||||
'town' => $validated['town'],
|
||||
'postcode' => $validated['postcode'],
|
||||
'country' => $validated['country'],
|
||||
'primary_parent_user_id' => $parentProfile?->user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($validated['parent_option'] === 'none') {
|
||||
$billingAccount->update(['primary_parent_user_id' => null]);
|
||||
}
|
||||
|
||||
// Ensure student profile points at the billing account we just resolved
|
||||
$student->billing_account_id = $billingAccount->id;
|
||||
$student->save();
|
||||
|
||||
// 5) Parent pivot handling
|
||||
if ($validated['parent_option'] === 'none') {
|
||||
$student->parents()->detach();
|
||||
} else {
|
||||
if ($parentProfile) {
|
||||
// ONE parent only:
|
||||
$student->parents()->sync([
|
||||
$parentProfile->id => [
|
||||
'relationship' => null,
|
||||
'notes' => null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()
|
||||
->route('admin.students.edit', $student->id)
|
||||
->with('success', 'Student updated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// -----------------------------
|
||||
// Validation
|
||||
// -----------------------------
|
||||
$validated = $request->validate([
|
||||
// Student
|
||||
'student_first_name' => 'required|string|max:255',
|
||||
'student_last_name' => 'required|string|max:255',
|
||||
'student_email' => 'required|email|unique:users,email',
|
||||
'student_phone' => 'nullable|string|max:255',
|
||||
'date_of_birth' => 'nullable|date',
|
||||
'student_type_id' => 'required|exists:student_types,id',
|
||||
|
||||
// Parent selection
|
||||
'parent_option' => 'required|in:none,existing,new',
|
||||
'existing_parent_id' => 'required_if:parent_option,existing|nullable|exists:parent_profiles,id',
|
||||
|
||||
// New parent
|
||||
'parent_first_name' => 'nullable|string|max:255',
|
||||
'parent_last_name' => 'nullable|string|max:255',
|
||||
'parent_email' => 'nullable|email|unique:users,email',
|
||||
'parent_phone' => 'nullable|string|max:255',
|
||||
|
||||
// Billing
|
||||
'billing_account_id' => 'sometimes|nullable|exists:billing_accounts,id',
|
||||
'billing_name' => 'required|string|max:255',
|
||||
'invoice_email' => 'required|email',
|
||||
'billing_phone' => 'nullable|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'address_line1' => 'required|string|max:255',
|
||||
'address_line2' => 'nullable|string|max:255',
|
||||
'town' => 'required|string|max:255',
|
||||
'postcode' => 'required|string|max:50',
|
||||
'country' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
|
||||
// ===============================================================
|
||||
// 1) Create STUDENT USER
|
||||
// ===============================================================
|
||||
$studentUser = User::create([
|
||||
'first_name' => $validated['student_first_name'],
|
||||
'last_name' => $validated['student_last_name'],
|
||||
'email' => $validated['student_email'],
|
||||
'phone' => $validated['student_phone'],
|
||||
'password' => Hash::make(str()->random(16)), // Temporary password
|
||||
]);
|
||||
|
||||
$studentUser->assignRole('student');
|
||||
|
||||
|
||||
// ===============================================================
|
||||
// 2) Handle PARENT selection
|
||||
// ===============================================================
|
||||
|
||||
$parentProfile = null;
|
||||
|
||||
if ($validated['parent_option'] === 'existing') {
|
||||
$parentProfile = ParentProfile::find($validated['existing_parent_id']);
|
||||
}
|
||||
|
||||
if ($validated['parent_option'] === 'new') {
|
||||
create([
|
||||
'first_name' => $validated['parent_first_name'],
|
||||
'last_name' => $validated['parent_last_name'],
|
||||
'email' => $validated['parent_email'],
|
||||
'phone' => $validated['parent_phone'],
|
||||
'password' => Hash::make(str()->random(16)),
|
||||
]);
|
||||
$parentUser->assignRole('parent');
|
||||
|
||||
$parentProfile = ParentProfile::firstOrCreate(
|
||||
['user_id' => $parentUser->id],
|
||||
['phone' => $parentUser->phone]
|
||||
);
|
||||
}
|
||||
|
||||
if ($parentProfile) {
|
||||
$parentProfile->user?->assignRole('parent');
|
||||
}
|
||||
|
||||
// Note: If parent_option == 'none', $parentUser remains null.
|
||||
|
||||
|
||||
// ===============================================================
|
||||
// 3) Create BILLING ACCOUNT
|
||||
// ===============================================================
|
||||
if (!empty($validated['billing_account_id'])) {
|
||||
// Use existing billing account
|
||||
$billingAccount = BillingAccount::find($validated['billing_account_id']);
|
||||
} else {
|
||||
$billingAccount = BillingAccount::create([
|
||||
'name' => $validated['billing_name'],
|
||||
'contact_name' => $validated['contact_name'],
|
||||
'invoice_email' => $validated['invoice_email'],
|
||||
'phone' => $validated['billing_phone'],
|
||||
'address_line1' => $validated['address_line1'],
|
||||
'address_line2' => $validated['address_line2'],
|
||||
'town' => $validated['town'],
|
||||
'postcode' => $validated['postcode'],
|
||||
'country' => $validated['country'],
|
||||
'primary_parent_user_id' => $parentProfile?->user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// 4) Create STUDENT PROFILE
|
||||
// ===============================================================
|
||||
$studentProfile = StudentProfile::create([
|
||||
'user_id' => $studentUser->id,
|
||||
'date_of_birth' => $validated['date_of_birth'] ?? null,
|
||||
'billing_account_id' => $billingAccount->id,
|
||||
'student_type_id' => $validated['student_type_id'],
|
||||
]);
|
||||
|
||||
// ===============================================================
|
||||
// 4B) Create/link PARENT PROFILE + pivot relationship
|
||||
// ===============================================================
|
||||
if ($parentUser) {
|
||||
|
||||
// Ensure parent has role (covers "existing parent" case)
|
||||
$parentUser->assignRole('parent');
|
||||
|
||||
// Ensure parent profile exists
|
||||
$parentProfile = ParentProfile::firstOrCreate(
|
||||
['user_id' => $parentUser->id],
|
||||
['phone' => $parentUser->phone] // optional default
|
||||
);
|
||||
|
||||
// Link parent profile to student profile via pivot
|
||||
$studentProfile->parents()->syncWithoutDetaching([
|
||||
$parentProfile->id => [
|
||||
'relationship' => null,
|
||||
'notes' => null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// 5) Send password setup email to student user
|
||||
// ===============================================================
|
||||
Password::sendResetLink(['email' => $studentUser->email]);
|
||||
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()
|
||||
->route('admin.students.index')
|
||||
->with('success', 'Student created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e; // (You can customise error messages later)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Models\User;
|
||||
use App\Models\TeacherProfile;
|
||||
use App\Models\Subject;
|
||||
use App\Models\Level;
|
||||
|
||||
class TeacherController extends Controller
|
||||
{
|
||||
|
||||
public function index()
|
||||
{
|
||||
$teachers = \App\Models\TeacherProfile::with('user')->get();
|
||||
|
||||
return view('admin.teachers.index', compact('teachers'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$subjects = Subject::orderBy('name')->get();
|
||||
$levels = Level::orderBy('name')->get();
|
||||
|
||||
return view('admin.teachers.create', compact('subjects', 'levels'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// Later we’ll add validation here
|
||||
|
||||
// Create the User
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
$user->assignRole('teacher');
|
||||
|
||||
// Handle profile picture upload (placeholder for now)
|
||||
$pathToFile = null;
|
||||
if ($request->hasFile('picture')) {
|
||||
$pathToFile = $request->file('picture')->store('teacher_pictures', 'public');
|
||||
}
|
||||
|
||||
// Create the TeacherProfile
|
||||
$teacher = TeacherProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'bio' => $request->bio,
|
||||
'location' => $request->location,
|
||||
'hourly_rate'=> $request->hourly_rate,
|
||||
'picture' => $pathToFile,
|
||||
]);
|
||||
|
||||
// For later: attach subjects and levels
|
||||
// $teacher->subjects()->sync($request->subjects);
|
||||
// $teacher->levels()->sync($request->levels);
|
||||
|
||||
// Redirect somewhere (dashboard for now)
|
||||
return redirect()->route('admin.dashboard')
|
||||
->with('success', 'Teacher added successfully!');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$users = User::with('roles')->get();
|
||||
$roles = Role::all();
|
||||
|
||||
return view('admin.users.index', compact('users', 'roles'));
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, User $user)
|
||||
{
|
||||
$request->validate([
|
||||
'role' => 'required|exists:roles,name',
|
||||
]);
|
||||
|
||||
$user->syncRoles([$request->role]);
|
||||
|
||||
return redirect()->back()->with('success', "{$user->name} is now a {$request->role}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?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\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
$request->session()->regenerate();
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
|
||||
if ($user->hasRole('admin')) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
if ($user->hasRole('god')) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
if ($user->hasRole('teacher') || $user->hasRole('tutor')) {
|
||||
return redirect()->route('teacher.dashboard');
|
||||
}
|
||||
if ($user->hasRole('student')) {
|
||||
return redirect()->route('student.dashboard');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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 Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
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\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $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.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -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->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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 Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('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),
|
||||
]);
|
||||
|
||||
//sync the DB column with Spatie’s roles
|
||||
$user->assignRole($user->role);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect()->route('student.dashboard');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BookingsListController extends Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
//namespace App\Http\Controllers\Student;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Course;
|
||||
use App\Models\Lesson;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Replace this with your real enrolments when ready:
|
||||
$courses = Course::with('subject', 'level')->get();
|
||||
|
||||
$otherCourses = Course::with('subject', 'level')
|
||||
->whereNotIn('id', $courses->pluck('id'))
|
||||
->get();
|
||||
|
||||
$lastLesson = Lesson::with([
|
||||
'chapter',
|
||||
'lessonSection.module.course.subject',
|
||||
'lessonSection.module.course.level',
|
||||
])->orderBy('id')->first();
|
||||
|
||||
return view('student.dashboard', [
|
||||
'courses' => $courses,
|
||||
'otherCourses' => $otherCourses,
|
||||
'lastLesson' => $lastLesson,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Home Automation</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
|
||||
<!-- MQTT.js (browser) -->
|
||||
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My Home Automation</h1>
|
||||
<div class="sub">MQTT control panel (LAN/VPN)</div>
|
||||
</header>
|
||||
<div id="top-menu"></div>
|
||||
<script>
|
||||
(async function loadMenu(){
|
||||
try {
|
||||
const r = await fetch('/assets/page-elements/nav.html');
|
||||
const html = await r.text();
|
||||
const mount = document.getElementById('top-menu');
|
||||
// Insert the HTML
|
||||
mount.outerHTML = html;
|
||||
|
||||
// Now highlight the current page link
|
||||
const nav = document.querySelector('.top-menu');
|
||||
if (!nav) return;
|
||||
|
||||
// Normalize both sides (strip trailing index.html and trailing slash)
|
||||
const normalize = (u) => {
|
||||
const url = new URL(u, location.origin);
|
||||
let p = url.pathname.replace(/index\.html$/i,'');
|
||||
if (p.length > 1 && p.endsWith('/')) p = p.slice(0,-1);
|
||||
return url.origin + p;
|
||||
};
|
||||
|
||||
const here = normalize(location.href);
|
||||
|
||||
nav.querySelectorAll('a').forEach(a => {
|
||||
const target = normalize(a.href);
|
||||
|
||||
// exact match OR “same origin + same base path” cases
|
||||
const isExact = target === here;
|
||||
const isRootMatch = (
|
||||
target === normalize(location.origin + '/') &&
|
||||
(here === normalize(location.origin + '/') || here === normalize(location.href))
|
||||
);
|
||||
|
||||
if (isExact || isRootMatch) {
|
||||
a.classList.add('is-active');
|
||||
a.setAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn('Top menu load failed:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="container">
|
||||
<!-- Connection Card -->
|
||||
<div class="card" id="conn-card">
|
||||
<h2>MQTT Connection</h2>
|
||||
<div class="stack">
|
||||
<div class="row">
|
||||
<div class="pill">
|
||||
<strong>Status:</strong>
|
||||
<span id="conn-status" class="mono">disconnected</span>
|
||||
<span id="auto-badge" class="auto-chip hidden">AUTO</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" id="connect-btn">Connect</button>
|
||||
<button class="btn ghost" id="disconnect-btn" disabled>Disconnect</button>
|
||||
</div>
|
||||
<div class="row" style="gap:16px; align-items:flex-end; flex-wrap:wrap;">
|
||||
<div style="min-width:220px; flex:1;">
|
||||
<div class="field">
|
||||
<label>Broker WebSocket URL <span class="mono notice">wss://automation.richardjolley.co.uk/mqtt</span></label>
|
||||
<input id="broker-url" type="text" value="wss://automation.richardjolley.co.uk/mqtt" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="min-width:160px;">
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input id="broker-user" type="text" value="17ChurchWalk" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="min-width:220px;">
|
||||
<div class="field">
|
||||
<label>Password</label>
|
||||
<input id="broker-pass" type="password" value="PantomimeFrequentedHouse" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="align-items:center; gap:10px;">
|
||||
<input type="checkbox" id="auto-connect" checked /> <label for="auto-connect">Auto-connect on load</label>
|
||||
</div>
|
||||
<div id="conn-hint" class="notice muted"></div>
|
||||
<div id="conn-error" class="notice error"></div>
|
||||
<div class="notice">Credentials are pre-filled for convenience and stored locally in your browser (localStorage). For stronger security, consider a tiny server-side MQTT proxy so the page never sees raw creds.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Link to Control Panel (existing) -->
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<div class="row">
|
||||
<a class="btn" href="https://control.richardjolley.co.uk">Open Control Panel</a>
|
||||
<span class="chip">LAN/VPN only</span>
|
||||
</div>
|
||||
<p class="notice">VPN and service management.</p>
|
||||
</div>
|
||||
|
||||
<!-- Office Light Card (Shelly color bulb) -->
|
||||
<div class="card" id="office-light">
|
||||
<div class="row" style="justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h2 style="margin:0;">Office Light</h2>
|
||||
<div class="row">
|
||||
<div id="ol-color" class="color-box" title="Current color"></div>
|
||||
<button id="ol-toggle" class="btn">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice mono" style="margin-top:6px;">Topic base: <span id="ol-base-topic">shellies/office-bulb/color/0</span></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Mode</label>
|
||||
<div class="seg" id="ol-mode">
|
||||
<button data-mode="color" class="active" type="button">Color</button>
|
||||
<button data-mode="white" type="button">White</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ol-color-controls">
|
||||
<div class="field">
|
||||
<label>Brightness (gain) <span class="mono" id="ol-bright-val">100</span></label>
|
||||
<input type="range" id="ol-bright" min="0" max="100" value="100" />
|
||||
</div>
|
||||
|
||||
<div class="row" style="gap:16px;">
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Red <span class="mono" id="ol-r-val">255</span></label>
|
||||
<input type="range" id="ol-r" min="0" max="255" value="255" />
|
||||
</div>
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Green <span class="mono" id="ol-g-val">255</span></label>
|
||||
<input type="range" id="ol-g" min="0" max="255" value="255" />
|
||||
</div>
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Blue <span class="mono" id="ol-b-val">255</span></label>
|
||||
<input type="range" id="ol-b" min="0" max="255" value="255" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>White channel (RGBW) <span class="mono" id="ol-w-val">0</span></label>
|
||||
<input type="range" id="ol-w" min="0" max="255" value="0" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Effect (0=off) <span class="mono" id="ol-effect-val">0</span></label>
|
||||
<input type="range" id="ol-effect" min="0" max="10" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ol-white-controls" class="hidden">
|
||||
<div class="field">
|
||||
<label>Brightness <span class="mono" id="ol-wbright-val">100</span></label>
|
||||
<input type="range" id="ol-wbright" min="0" max="100" value="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Color Temperature (K) <span class="mono" id="ol-temp-val">3000</span></label>
|
||||
<input type="range" id="ol-temp" min="2700" max="6500" step="10" value="3000" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Effect (0=off) <span class="mono" id="ol-effectw-val">0</span></label>
|
||||
<input type="range" id="ol-effectw" min="0" max="10" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice">Shelly expects updates on <span class="mono">{base}/set</span> as JSON; on/off on <span class="mono">{base}/command</span>. We also listen to <span class="mono">{base}/status</span>.</div>
|
||||
<div id="ol-feedback" class="notice"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="office-plug-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Office Plug Lamp</h2>
|
||||
<div class="row off" id="office-plug-state">
|
||||
<span class="status-dot"></span>
|
||||
<span class="mono" id="office-plug-text">off</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice mono" style="margin-top:6px;">Prefix: <span>office-plug-lamp</span> · RPC topic: <span>office-plug-lamp/rpc</span></div>
|
||||
<div class="row" style="margin-top:10px; gap:8px;">
|
||||
<button class="btn small" id="office-plug-on">On</button>
|
||||
<button class="btn small" id="office-plug-off">Off</button>
|
||||
<button class="btn small" id="office-plug-toggle">Toggle</button>
|
||||
</div>
|
||||
<div class="notice" id="office-plug-fb"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="living-plug-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Living Room Plug Lamp</h2>
|
||||
<div class="row off" id="living-plug-state">
|
||||
<span class="status-dot"></span>
|
||||
<span class="mono" id="living-plug-text">off</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice mono" style="margin-top:6px;">Prefix: <span>livingroom-plug-lamp</span> · RPC topic: <span>livingroom-plug-lamp/rpc</span></div>
|
||||
<div class="row" style="margin-top:10px; gap:8px;">
|
||||
<button class="btn small" id="living-plug-on">On</button>
|
||||
<button class="btn small" id="living-plug-off">Off</button>
|
||||
<button class="btn small" id="living-plug-toggle">Toggle</button>
|
||||
</div>
|
||||
<div class="notice" id="living-plug-fb"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const qs = s => document.querySelector(s);
|
||||
const byId = id => document.getElementById(id);
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const debounce = (fn, ms=200) => { let t; return (...a) => { clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; };
|
||||
|
||||
const storage = {
|
||||
get() {
|
||||
return {
|
||||
url: localStorage.getItem('mqtt_url') || 'wss://automation.richardjolley.co.uk/mqtt',
|
||||
user: localStorage.getItem('mqtt_user') || '17ChurchWalk',
|
||||
pass: localStorage.getItem('mqtt_pass') || 'PantomimeFrequentedHouse',
|
||||
auto: localStorage.getItem('mqtt_auto') !== '0'
|
||||
};
|
||||
},
|
||||
set({url,user,pass,auto}) {
|
||||
localStorage.setItem('mqtt_url', url);
|
||||
localStorage.setItem('mqtt_user', user || '');
|
||||
localStorage.setItem('mqtt_pass', pass || '');
|
||||
localStorage.setItem('mqtt_auto', auto ? '1':'0');
|
||||
}
|
||||
};
|
||||
|
||||
const elConnStatus = byId('conn-status');
|
||||
const elConnect = byId('connect-btn');
|
||||
const elDisconnect = byId('disconnect-btn');
|
||||
const elUrl = byId('broker-url');
|
||||
const elUser = byId('broker-user');
|
||||
const elPass = byId('broker-pass');
|
||||
const elAuto = byId('auto-connect');
|
||||
const elAutoBadge = byId('auto-badge');
|
||||
const elHint = byId('conn-hint');
|
||||
const elErr = byId('conn-error');
|
||||
|
||||
const devices = [
|
||||
{ kind:'bulb', name:'Office Light', base:'shellies/office-bulb/color/0',
|
||||
ids:{ toggle:'ol-toggle', color:'ol-color', modeSeg:'ol-mode', R:'ol-r', G:'ol-g', B:'ol-b', W:'ol-w', Rv:'ol-r-val', Gv:'ol-g-val', Bv:'ol-b-val', Wv:'ol-w-val', bright:'ol-bright', brightv:'ol-bright-val', effect:'ol-effect', effectv:'ol-effect-val', wbright:'ol-wbright', wbrightv:'ol-wbright-val', temp:'ol-temp', tempv:'ol-temp-val', effectw:'ol-effectw', effectwv:'ol-effectw-val', colorControls:'ol-color-controls', whiteControls:'ol-white-controls', feedback:'ol-feedback' },
|
||||
state:{ on:false, mode:'color', r:255,g:255,b:255,w:0,gain:100,effect:0, brightness:100,temp:3000,effectw:0 }
|
||||
},
|
||||
{ kind:'plug', prefix:'office-plug-lamp', switchId:0, els:{ stateWrap:'office-plug-state', stateText:'office-plug-text', on:'office-plug-on', off:'office-plug-off', toggle:'office-plug-toggle', fb:'office-plug-fb' }, state:{ on:false } },
|
||||
{ kind:'plug', prefix:'livingroom-plug-lamp', switchId:0, els:{ stateWrap:'living-plug-state', stateText:'living-plug-text', on:'living-plug-on', off:'living-plug-off', toggle:'living-plug-toggle', fb:'living-plug-fb' }, state:{ on:false } }
|
||||
];
|
||||
|
||||
(function initConnForm(){
|
||||
const {url,user,pass,auto} = storage.get();
|
||||
elUrl.value = url; elUser.value = user; elPass.value = pass; elAuto.checked = auto;
|
||||
elAutoBadge.classList.toggle('hidden', !auto);
|
||||
// Show helpful hint if https + ws
|
||||
if (location.protocol === 'https:' && url.startsWith('ws://')) {
|
||||
elHint.innerHTML = 'Mixed content blocked: this page is https but the broker is ws://. Use <code class="inline">wss://automation.richardjolley.co.uk/mqtt</code>.';
|
||||
} else {
|
||||
elHint.textContent = '';
|
||||
}
|
||||
})();
|
||||
|
||||
let client = null;
|
||||
|
||||
function setConnStatus(text, ok=false) {
|
||||
elConnStatus.textContent = text;
|
||||
elConnStatus.className = 'mono ' + (ok ? 'success' : (text==='disconnected'?'':'warn'));
|
||||
elConnect.disabled = !!ok;
|
||||
elDisconnect.disabled = !ok;
|
||||
}
|
||||
|
||||
function showError(msg){ elErr.textContent = msg || ''; }
|
||||
|
||||
function connectMQTT(){
|
||||
showError('');
|
||||
const url = elUrl.value.trim();
|
||||
const username = elUser.value.trim() || undefined;
|
||||
const password = elPass.value || undefined;
|
||||
storage.set({url, user: username||'', pass: password||'', auto: elAuto.checked});
|
||||
|
||||
if (typeof mqtt === 'undefined') {
|
||||
showError('MQTT library failed to load. Check network/CSP and the CDN.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (location.protocol === 'https:' && url.startsWith('ws://')) {
|
||||
showError('Mixed content blocked: use wss://automation.richardjolley.co.uk/mqtt');
|
||||
setConnStatus('blocked');
|
||||
return;
|
||||
}
|
||||
|
||||
try { if (client) { client.end(true); client = null; } } catch(e) {}
|
||||
|
||||
setConnStatus('connecting…');
|
||||
client = mqtt.connect(url, { username, password, reconnectPeriod: 2000 });
|
||||
|
||||
client.on('connect', () => {
|
||||
setConnStatus('connected', true);
|
||||
devices.forEach(d => {
|
||||
if (d.kind === 'bulb') {
|
||||
try { client.subscribe(`${d.base}/status`); } catch(e){}
|
||||
try { client.subscribe(`${d.base}`); } catch(e){}
|
||||
} else if (d.kind === 'plug') {
|
||||
try { client.subscribe(`${d.prefix}/events/rpc`); } catch(e){}
|
||||
}
|
||||
});
|
||||
requestStatuses();
|
||||
});
|
||||
|
||||
client.on('reconnect', () => setConnStatus('reconnecting…'));
|
||||
client.on('close', () => setConnStatus('disconnected'));
|
||||
client.on('error', (err) => {
|
||||
setConnStatus('error');
|
||||
showError('Connection error: ' + (err && err.message ? err.message : String(err)));
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
client.on('message', (topic, payloadBuf) => {
|
||||
const payload = payloadBuf.toString();
|
||||
const bulb = devices[0];
|
||||
if (topic === bulb.base) {
|
||||
bulb.state.on = (payload.trim().toLowerCase() === 'on');
|
||||
updateBulbUI(bulb);
|
||||
return;
|
||||
}
|
||||
if (topic === `${bulb.base}/status`) {
|
||||
try {
|
||||
const js = JSON.parse(payload);
|
||||
if (typeof js.ison === 'boolean') bulb.state.on = js.ison;
|
||||
if (js.mode === 'color' || js.mode === 'white') bulb.state.mode = js.mode;
|
||||
if (Number.isFinite(js.red)) bulb.state.r = clamp(js.red,0,255);
|
||||
if (Number.isFinite(js.green)) bulb.state.g = clamp(js.green,0,255);
|
||||
if (Number.isFinite(js.blue)) bulb.state.b = clamp(js.blue,0,255);
|
||||
if (Number.isFinite(js.white)) bulb.state.w = clamp(js.white,0,255);
|
||||
if (Number.isFinite(js.gain)) bulb.state.gain = clamp(js.gain,0,100);
|
||||
if (Number.isFinite(js.effect)) bulb.state.effect = clamp(js.effect,0,10);
|
||||
if (Number.isFinite(js.brightness)) bulb.state.brightness = clamp(js.brightness,0,100);
|
||||
if (Number.isFinite(js.temp)) bulb.state.temp = clamp(js.temp,2700,6500);
|
||||
updateBulbUI(bulb);
|
||||
} catch(e) { console.warn('Bad JSON on status', e, payload); }
|
||||
return;
|
||||
}
|
||||
|
||||
const plug = devices.find(d => d.kind==='plug' && topic === `${d.prefix}/events/rpc`);
|
||||
if (plug) {
|
||||
try {
|
||||
const js = JSON.parse(payload);
|
||||
if (js.method === 'NotifyStatus' && js.params && js.params[`switch:${plug.switchId}`]) {
|
||||
const out = js.params[`switch:${plug.switchId}`].output;
|
||||
if (typeof out === 'boolean') { plug.state.on = out; updatePlugUI(plug); }
|
||||
}
|
||||
} catch(e) { console.warn('Bad RPC JSON', e, payload); }
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requestStatuses(){
|
||||
devices.filter(d=>d.kind==='plug').forEach((plug,i) => {
|
||||
const req = { id: 100+i, src: 'ui', method: 'Shelly.GetStatus' };
|
||||
publishRPC(plug, req);
|
||||
});
|
||||
}
|
||||
|
||||
function publish(dev, subPath, message) {
|
||||
if (!client || client.disconnected) return false;
|
||||
const topic = `${dev.base}${subPath ? '/' + subPath : ''}`;
|
||||
try { client.publish(topic, message, { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
|
||||
}
|
||||
|
||||
function publishRPC(plug, obj){
|
||||
if (!client || client.disconnected) return false;
|
||||
const topic = `${plug.prefix}/rpc`;
|
||||
try { client.publish(topic, JSON.stringify(obj), { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
|
||||
}
|
||||
|
||||
function updateBulbUI(dev){
|
||||
const ids = dev.ids;
|
||||
const seg = byId(ids.modeSeg);
|
||||
seg.querySelectorAll('button').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === dev.state.mode));
|
||||
byId(ids.colorControls).classList.toggle('hidden', dev.state.mode !== 'color');
|
||||
byId(ids.whiteControls).classList.toggle('hidden', dev.state.mode !== 'white');
|
||||
byId(ids.color).style.background = `rgb(${dev.state.r} ${dev.state.g} ${dev.state.b})`;
|
||||
byId(ids.toggle).textContent = dev.state.on ? 'Turn Off' : 'Turn On';
|
||||
byId(ids.R).value = dev.state.r; byId(ids.Rv).textContent = dev.state.r;
|
||||
byId(ids.G).value = dev.state.g; byId(ids.Gv).textContent = dev.state.g;
|
||||
byId(ids.B).value = dev.state.b; byId(ids.Bv).textContent = dev.state.b;
|
||||
byId(ids.W).value = dev.state.w; byId(ids.Wv).textContent = dev.state.w;
|
||||
byId(ids.bright).value = dev.state.gain; byId(ids.brightv).textContent = dev.state.gain;
|
||||
byId(ids.effect).value = dev.state.effect; byId(ids.effectv).textContent = dev.state.effect;
|
||||
byId(ids.wbright).value = dev.state.brightness; byId(ids.wbrightv).textContent = dev.state.brightness;
|
||||
byId(ids.temp).value = dev.state.temp; byId(ids.tempv).textContent = dev.state.temp;
|
||||
byId(ids.effectw).value = dev.state.effectw; byId(ids.effectwv).textContent = dev.state.effectw;
|
||||
}
|
||||
|
||||
function sendBulbUpdate(dev){
|
||||
const s = dev.state; let body;
|
||||
if (s.mode === 'color') { body = { mode:'color', red:s.r, green:s.g, blue:s.b, white:s.w, gain:s.gain, effect:s.effect, turn:'on' }; }
|
||||
else { body = { mode:'white', brightness:s.brightness, temp:s.temp, effect:s.effectw, turn:'on' }; }
|
||||
const ok = publish(dev, 'set', JSON.stringify(body));
|
||||
byId(dev.ids.feedback).textContent = ok ? `Set → ${JSON.stringify(body)}` : 'Not connected';
|
||||
}
|
||||
|
||||
function toggleBulbPower(dev){
|
||||
const target = dev.state.on ? 'off' : 'on';
|
||||
const ok = publish(dev, 'command', target);
|
||||
byId(dev.ids.feedback).textContent = ok ? `Power ${target}` : 'Not connected';
|
||||
}
|
||||
|
||||
function updatePlugUI(plug){
|
||||
const wrap = byId(plug.els.stateWrap); const text = byId(plug.els.stateText);
|
||||
wrap.classList.toggle('on', plug.state.on); wrap.classList.toggle('off', !plug.state.on);
|
||||
text.textContent = plug.state.on ? 'on' : 'off';
|
||||
}
|
||||
|
||||
function plugOn(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:true } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → on' : 'Not connected'; }
|
||||
function plugOff(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:false } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → off' : 'Not connected'; }
|
||||
function plugToggle(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Toggle', params:{ id: plug.switchId } }); byId(plug.els.fb).textContent = ok ? 'Switch.Toggle' : 'Not connected'; }
|
||||
|
||||
(function initOfficeLight(){
|
||||
const d = devices[0]; const ids = d.ids; const apply = debounce(() => sendBulbUpdate(d), 180);
|
||||
byId(ids.modeSeg).querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { d.state.mode = btn.dataset.mode; updateBulbUI(d); apply(); }); });
|
||||
byId(ids.R).addEventListener('input', e => { d.state.r = +e.target.value; byId(ids.Rv).textContent = d.state.r; updateBulbUI(d); apply(); });
|
||||
byId(ids.G).addEventListener('input', e => { d.state.g = +e.target.value; byId(ids.Gv).textContent = d.state.g; updateBulbUI(d); apply(); });
|
||||
byId(ids.B).addEventListener('input', e => { d.state.b = +e.target.value; byId(ids.Bv).textContent = d.state.b; updateBulbUI(d); apply(); });
|
||||
byId(ids.W).addEventListener('input', e => { d.state.w = +e.target.value; byId(ids.Wv).textContent = d.state.w; updateBulbUI(d); apply(); });
|
||||
byId(ids.bright).addEventListener('input', e => { d.state.gain = +e.target.value; byId(ids.brightv).textContent = d.state.gain; updateBulbUI(d); apply(); });
|
||||
byId(ids.effect).addEventListener('input', e => { d.state.effect = +e.target.value; byId(ids.effectv).textContent = d.state.effect; updateBulbUI(d); apply(); });
|
||||
byId(ids.wbright).addEventListener('input', e => { d.state.brightness = +e.target.value; byId(ids.wbrightv).textContent = d.state.brightness; updateBulbUI(d); apply(); });
|
||||
byId(ids.temp).addEventListener('input', e => { d.state.temp = +e.target.value; byId(ids.tempv).textContent = d.state.temp; updateBulbUI(d); apply(); });
|
||||
byId(ids.effectw).addEventListener('input', e => { d.state.effectw = +e.target.value; byId(ids.effectwv).textContent = d.state.effectw; updateBulbUI(d); apply(); });
|
||||
byId(ids.toggle).addEventListener('click', () => { toggleBulbPower(d); }); updateBulbUI(d);
|
||||
})();
|
||||
|
||||
(function initPlugs(){
|
||||
[{id:'office'}, {id:'living'}].forEach(k=>{}); // noop placeholder to keep structure tidy
|
||||
devices.filter(d=>d.kind==='plug').forEach(plug => {
|
||||
byId(plug.els.on).addEventListener('click', ()=>plugOn(plug));
|
||||
byId(plug.els.off).addEventListener('click', ()=>plugOff(plug));
|
||||
byId(plug.els.toggle).addEventListener('click', ()=>plugToggle(plug));
|
||||
updatePlugUI(plug);
|
||||
});
|
||||
})();
|
||||
|
||||
elConnect.addEventListener('click', connectMQTT);
|
||||
elDisconnect.addEventListener('click', () => { if (client) { try { client.end(true); } catch(e){} } setConnStatus('disconnected'); });
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const {auto} = storage.get();
|
||||
elAuto.checked = auto; elAutoBadge.classList.toggle('hidden', !auto);
|
||||
if (auto) { try { connectMQTT(); } catch (e) { console.warn('Auto-connect failed', e); showError('Auto-connect failed: ' + e.message); } }
|
||||
});
|
||||
|
||||
elAuto.addEventListener('change', () => {
|
||||
storage.set({ url: elUrl.value, user: elUser.value, pass: elPass.value, auto: elAuto.checked });
|
||||
elAutoBadge.classList.toggle('hidden', !elAuto.checked);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,432 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Home Automation</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
|
||||
<!-- MQTT.js (browser) -->
|
||||
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My Home Automation</h1>
|
||||
<div class="sub">MQTT control panel (LAN/VPN)</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- Connection Card -->
|
||||
<div class="card" id="conn-card">
|
||||
<h2>MQTT Connection</h2>
|
||||
<div class="stack">
|
||||
<div class="row">
|
||||
<div class="pill">
|
||||
<strong>Status:</strong>
|
||||
<span id="conn-status" class="mono">disconnected</span>
|
||||
<span id="auto-badge" class="auto-chip hidden">AUTO</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" id="connect-btn">Connect</button>
|
||||
<button class="btn ghost" id="disconnect-btn" disabled>Disconnect</button>
|
||||
</div>
|
||||
<div class="row" style="gap:16px; align-items:flex-end; flex-wrap:wrap;">
|
||||
<div style="min-width:220px; flex:1;">
|
||||
<div class="field">
|
||||
<label>Broker WebSocket URL <span class="mono notice">ws://192.168.1.237:9001</span></label>
|
||||
<input id="broker-url" type="text" value="ws://192.168.1.237:9001" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="min-width:160px;">
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input id="broker-user" type="text" value="17ChurchWalk" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="min-width:220px;">
|
||||
<div class="field">
|
||||
<label>Password</label>
|
||||
<input id="broker-pass" type="password" value="PantomimeFrequentedHouse" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="align-items:center; gap:10px;">
|
||||
<input type="checkbox" id="auto-connect" checked /> <label for="auto-connect">Auto-connect on load</label>
|
||||
</div>
|
||||
<div id="conn-hint" class="notice muted"></div>
|
||||
<div id="conn-error" class="notice error"></div>
|
||||
<div class="notice">Credentials are pre-filled for convenience and stored locally in your browser (localStorage). For stronger security, consider a tiny server-side MQTT proxy so the page never sees raw creds.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Link to Control Panel (existing) -->
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<div class="row">
|
||||
<a class="btn" href="http://control.richardjolley.co.uk">Open Control Panel</a>
|
||||
<span class="chip">LAN/VPN only</span>
|
||||
</div>
|
||||
<p class="notice">VPN and service management.</p>
|
||||
</div>
|
||||
|
||||
<!-- Office Light Card (Shelly color bulb) -->
|
||||
<div class="card" id="office-light">
|
||||
<div class="row" style="justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h2 style="margin:0;">Office Light</h2>
|
||||
<div class="row">
|
||||
<div id="ol-color" class="color-box" title="Current color"></div>
|
||||
<button id="ol-toggle" class="btn">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice mono" style="margin-top:6px;">Topic base: <span id="ol-base-topic">shellies/office-bulb/color/0</span></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Mode</label>
|
||||
<div class="seg" id="ol-mode">
|
||||
<button data-mode="color" class="active" type="button">Color</button>
|
||||
<button data-mode="white" type="button">White</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ol-color-controls">
|
||||
<div class="field">
|
||||
<label>Brightness (gain) <span class="mono" id="ol-bright-val">100</span></label>
|
||||
<input type="range" id="ol-bright" min="0" max="100" value="100" />
|
||||
</div>
|
||||
|
||||
<div class="row" style="gap:16px;">
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Red <span class="mono" id="ol-r-val">255</span></label>
|
||||
<input type="range" id="ol-r" min="0" max="255" value="255" />
|
||||
</div>
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Green <span class="mono" id="ol-g-val">255</span></label>
|
||||
<input type="range" id="ol-g" min="0" max="255" value="255" />
|
||||
</div>
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Blue <span class="mono" id="ol-b-val">255</span></label>
|
||||
<input type="range" id="ol-b" min="0" max="255" value="255" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>White channel (RGBW) <span class="mono" id="ol-w-val">0</span></label>
|
||||
<input type="range" id="ol-w" min="0" max="255" value="0" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Effect (0=off) <span class="mono" id="ol-effect-val">0</span></label>
|
||||
<input type="range" id="ol-effect" min="0" max="10" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ol-white-controls" class="hidden">
|
||||
<div class="field">
|
||||
<label>Brightness <span class="mono" id="ol-wbright-val">100</span></label>
|
||||
<input type="range" id="ol-wbright" min="0" max="100" value="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Color Temperature (K) <span class="mono" id="ol-temp-val">3000</span></label>
|
||||
<input type="range" id="ol-temp" min="2700" max="6500" step="10" value="3000" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Effect (0=off) <span class="mono" id="ol-effectw-val">0</span></label>
|
||||
<input type="range" id="ol-effectw" min="0" max="10" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice">Shelly expects updates on <span class="mono">{base}/set</span> as JSON; on/off on <span class="mono">{base}/command</span>. We also listen to <span class="mono">{base}/status</span>.</div>
|
||||
<div id="ol-feedback" class="notice"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="office-plug-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Office Plug Lamp</h2>
|
||||
<div class="row off" id="office-plug-state">
|
||||
<span class="status-dot"></span>
|
||||
<span class="mono" id="office-plug-text">off</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice mono" style="margin-top:6px;">Prefix: <span>office-plug-lamp</span> · RPC topic: <span>office-plug-lamp/rpc</span></div>
|
||||
<div class="row" style="margin-top:10px; gap:8px;">
|
||||
<button class="btn small" id="office-plug-on">On</button>
|
||||
<button class="btn small" id="office-plug-off">Off</button>
|
||||
<button class="btn small" id="office-plug-toggle">Toggle</button>
|
||||
</div>
|
||||
<div class="notice" id="office-plug-fb"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="living-plug-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Living Room Plug Lamp</h2>
|
||||
<div class="row off" id="living-plug-state">
|
||||
<span class="status-dot"></span>
|
||||
<span class="mono" id="living-plug-text">off</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice mono" style="margin-top:6px;">Prefix: <span>livingroom-plug-lamp</span> · RPC topic: <span>livingroom-plug-lamp/rpc</span></div>
|
||||
<div class="row" style="margin-top:10px; gap:8px;">
|
||||
<button class="btn small" id="living-plug-on">On</button>
|
||||
<button class="btn small" id="living-plug-off">Off</button>
|
||||
<button class="btn small" id="living-plug-toggle">Toggle</button>
|
||||
</div>
|
||||
<div class="notice" id="living-plug-fb"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const qs = s => document.querySelector(s);
|
||||
const byId = id => document.getElementById(id);
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const debounce = (fn, ms=200) => { let t; return (...a) => { clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; };
|
||||
|
||||
const storage = {
|
||||
get() {
|
||||
return {
|
||||
url: localStorage.getItem('mqtt_url') || 'ws://192.168.1.237:9001',
|
||||
user: localStorage.getItem('mqtt_user') || '17ChurchWalk',
|
||||
pass: localStorage.getItem('mqtt_pass') || 'PantomimeFrequentedHouse',
|
||||
auto: localStorage.getItem('mqtt_auto') !== '0'
|
||||
};
|
||||
},
|
||||
set({url,user,pass,auto}) {
|
||||
localStorage.setItem('mqtt_url', url);
|
||||
localStorage.setItem('mqtt_user', user || '');
|
||||
localStorage.setItem('mqtt_pass', pass || '');
|
||||
localStorage.setItem('mqtt_auto', auto ? '1':'0');
|
||||
}
|
||||
};
|
||||
|
||||
const elConnStatus = byId('conn-status');
|
||||
const elConnect = byId('connect-btn');
|
||||
const elDisconnect = byId('disconnect-btn');
|
||||
const elUrl = byId('broker-url');
|
||||
const elUser = byId('broker-user');
|
||||
const elPass = byId('broker-pass');
|
||||
const elAuto = byId('auto-connect');
|
||||
const elAutoBadge = byId('auto-badge');
|
||||
const elHint = byId('conn-hint');
|
||||
const elErr = byId('conn-error');
|
||||
|
||||
const devices = [
|
||||
{ kind:'bulb', name:'Office Light', base:'shellies/office-bulb/color/0',
|
||||
ids:{ toggle:'ol-toggle', color:'ol-color', modeSeg:'ol-mode', R:'ol-r', G:'ol-g', B:'ol-b', W:'ol-w', Rv:'ol-r-val', Gv:'ol-g-val', Bv:'ol-b-val', Wv:'ol-w-val', bright:'ol-bright', brightv:'ol-bright-val', effect:'ol-effect', effectv:'ol-effect-val', wbright:'ol-wbright', wbrightv:'ol-wbright-val', temp:'ol-temp', tempv:'ol-temp-val', effectw:'ol-effectw', effectwv:'ol-effectw-val', colorControls:'ol-color-controls', whiteControls:'ol-white-controls', feedback:'ol-feedback' },
|
||||
state:{ on:false, mode:'color', r:255,g:255,b:255,w:0,gain:100,effect:0, brightness:100,temp:3000,effectw:0 }
|
||||
},
|
||||
{ kind:'plug', prefix:'office-plug-lamp', switchId:0, els:{ stateWrap:'office-plug-state', stateText:'office-plug-text', on:'office-plug-on', off:'office-plug-off', toggle:'office-plug-toggle', fb:'office-plug-fb' }, state:{ on:false } },
|
||||
{ kind:'plug', prefix:'livingroom-plug-lamp', switchId:0, els:{ stateWrap:'living-plug-state', stateText:'living-plug-text', on:'living-plug-on', off:'living-plug-off', toggle:'living-plug-toggle', fb:'living-plug-fb' }, state:{ on:false } }
|
||||
];
|
||||
|
||||
(function initConnForm(){
|
||||
const {url,user,pass,auto} = storage.get();
|
||||
elUrl.value = url; elUser.value = user; elPass.value = pass; elAuto.checked = auto;
|
||||
elAutoBadge.classList.toggle('hidden', !auto);
|
||||
// Show helpful hint if https + ws
|
||||
if (location.protocol === 'https:' && url.startsWith('ws://')) {
|
||||
elHint.innerHTML = 'This page is served over <code class="inline">https</code> but the broker URL is <code class="inline">ws://</code>. Browsers block mixed content. Either open this page via <code class="inline">http://</code> on your LAN, or change the broker to <code class="inline">wss://</code> and enable a TLS WebSocket listener in Mosquitto.';
|
||||
} else {
|
||||
elHint.textContent = '';
|
||||
}
|
||||
})();
|
||||
|
||||
let client = null;
|
||||
|
||||
function setConnStatus(text, ok=false) {
|
||||
elConnStatus.textContent = text;
|
||||
elConnStatus.className = 'mono ' + (ok ? 'success' : (text==='disconnected'?'':'warn'));
|
||||
elConnect.disabled = !!ok;
|
||||
elDisconnect.disabled = !ok;
|
||||
}
|
||||
|
||||
function showError(msg){ elErr.textContent = msg || ''; }
|
||||
|
||||
function connectMQTT(){
|
||||
showError('');
|
||||
const url = elUrl.value.trim();
|
||||
const username = elUser.value.trim() || undefined;
|
||||
const password = elPass.value || undefined;
|
||||
storage.set({url, user: username||'', pass: password||'', auto: elAuto.checked});
|
||||
|
||||
if (typeof mqtt === 'undefined') {
|
||||
showError('MQTT library failed to load. Check network/CSP and the CDN.');
|
||||
return;
|
||||
}
|
||||
if (location.protocol === 'https:' && url.startsWith('ws://')) {
|
||||
showError('Mixed content blocked: change broker URL to wss:// or open this page via http://');
|
||||
setConnStatus('blocked');
|
||||
return;
|
||||
}
|
||||
|
||||
try { if (client) { client.end(true); client = null; } } catch(e) {}
|
||||
|
||||
setConnStatus('connecting…');
|
||||
client = mqtt.connect(url, { username, password, reconnectPeriod: 2000 });
|
||||
|
||||
client.on('connect', () => {
|
||||
setConnStatus('connected', true);
|
||||
devices.forEach(d => {
|
||||
if (d.kind === 'bulb') {
|
||||
try { client.subscribe(`${d.base}/status`); } catch(e){}
|
||||
try { client.subscribe(`${d.base}`); } catch(e){}
|
||||
} else if (d.kind === 'plug') {
|
||||
try { client.subscribe(`${d.prefix}/events/rpc`); } catch(e){}
|
||||
}
|
||||
});
|
||||
requestStatuses();
|
||||
});
|
||||
|
||||
client.on('reconnect', () => setConnStatus('reconnecting…'));
|
||||
client.on('close', () => setConnStatus('disconnected'));
|
||||
client.on('error', (err) => {
|
||||
setConnStatus('error');
|
||||
showError('Connection error: ' + (err && err.message ? err.message : String(err)));
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
client.on('message', (topic, payloadBuf) => {
|
||||
const payload = payloadBuf.toString();
|
||||
const bulb = devices[0];
|
||||
if (topic === bulb.base) {
|
||||
bulb.state.on = (payload.trim().toLowerCase() === 'on');
|
||||
updateBulbUI(bulb);
|
||||
return;
|
||||
}
|
||||
if (topic === `${bulb.base}/status`) {
|
||||
try {
|
||||
const js = JSON.parse(payload);
|
||||
if (typeof js.ison === 'boolean') bulb.state.on = js.ison;
|
||||
if (js.mode === 'color' || js.mode === 'white') bulb.state.mode = js.mode;
|
||||
if (Number.isFinite(js.red)) bulb.state.r = clamp(js.red,0,255);
|
||||
if (Number.isFinite(js.green)) bulb.state.g = clamp(js.green,0,255);
|
||||
if (Number.isFinite(js.blue)) bulb.state.b = clamp(js.blue,0,255);
|
||||
if (Number.isFinite(js.white)) bulb.state.w = clamp(js.white,0,255);
|
||||
if (Number.isFinite(js.gain)) bulb.state.gain = clamp(js.gain,0,100);
|
||||
if (Number.isFinite(js.effect)) bulb.state.effect = clamp(js.effect,0,10);
|
||||
if (Number.isFinite(js.brightness)) bulb.state.brightness = clamp(js.brightness,0,100);
|
||||
if (Number.isFinite(js.temp)) bulb.state.temp = clamp(js.temp,2700,6500);
|
||||
updateBulbUI(bulb);
|
||||
} catch(e) { console.warn('Bad JSON on status', e, payload); }
|
||||
return;
|
||||
}
|
||||
|
||||
const plug = devices.find(d => d.kind==='plug' && topic === `${d.prefix}/events/rpc`);
|
||||
if (plug) {
|
||||
try {
|
||||
const js = JSON.parse(payload);
|
||||
if (js.method === 'NotifyStatus' && js.params && js.params[`switch:${plug.switchId}`]) {
|
||||
const out = js.params[`switch:${plug.switchId}`].output;
|
||||
if (typeof out === 'boolean') { plug.state.on = out; updatePlugUI(plug); }
|
||||
}
|
||||
} catch(e) { console.warn('Bad RPC JSON', e, payload); }
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requestStatuses(){
|
||||
devices.filter(d=>d.kind==='plug').forEach((plug,i) => {
|
||||
const req = { id: 100+i, src: 'ui', method: 'Shelly.GetStatus' };
|
||||
publishRPC(plug, req);
|
||||
});
|
||||
}
|
||||
|
||||
function publish(dev, subPath, message) {
|
||||
if (!client || client.disconnected) return false;
|
||||
const topic = `${dev.base}${subPath ? '/' + subPath : ''}`;
|
||||
try { client.publish(topic, message, { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
|
||||
}
|
||||
|
||||
function publishRPC(plug, obj){
|
||||
if (!client || client.disconnected) return false;
|
||||
const topic = `${plug.prefix}/rpc`;
|
||||
try { client.publish(topic, JSON.stringify(obj), { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
|
||||
}
|
||||
|
||||
function updateBulbUI(dev){
|
||||
const ids = dev.ids;
|
||||
const seg = byId(ids.modeSeg);
|
||||
seg.querySelectorAll('button').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === dev.state.mode));
|
||||
byId(ids.colorControls).classList.toggle('hidden', dev.state.mode !== 'color');
|
||||
byId(ids.whiteControls).classList.toggle('hidden', dev.state.mode !== 'white');
|
||||
byId(ids.color).style.background = `rgb(${dev.state.r} ${dev.state.g} ${dev.state.b})`;
|
||||
byId(ids.toggle).textContent = dev.state.on ? 'Turn Off' : 'Turn On';
|
||||
byId(ids.R).value = dev.state.r; byId(ids.Rv).textContent = dev.state.r;
|
||||
byId(ids.G).value = dev.state.g; byId(ids.Gv).textContent = dev.state.g;
|
||||
byId(ids.B).value = dev.state.b; byId(ids.Bv).textContent = dev.state.b;
|
||||
byId(ids.W).value = dev.state.w; byId(ids.Wv).textContent = dev.state.w;
|
||||
byId(ids.bright).value = dev.state.gain; byId(ids.brightv).textContent = dev.state.gain;
|
||||
byId(ids.effect).value = dev.state.effect; byId(ids.effectv).textContent = dev.state.effect;
|
||||
byId(ids.wbright).value = dev.state.brightness; byId(ids.wbrightv).textContent = dev.state.brightness;
|
||||
byId(ids.temp).value = dev.state.temp; byId(ids.tempv).textContent = dev.state.temp;
|
||||
byId(ids.effectw).value = dev.state.effectw; byId(ids.effectwv).textContent = dev.state.effectw;
|
||||
}
|
||||
|
||||
function sendBulbUpdate(dev){
|
||||
const s = dev.state; let body;
|
||||
if (s.mode === 'color') { body = { mode:'color', red:s.r, green:s.g, blue:s.b, white:s.w, gain:s.gain, effect:s.effect, turn:'on' }; }
|
||||
else { body = { mode:'white', brightness:s.brightness, temp:s.temp, effect:s.effectw, turn:'on' }; }
|
||||
const ok = publish(dev, 'set', JSON.stringify(body));
|
||||
byId(dev.ids.feedback).textContent = ok ? `Set → ${JSON.stringify(body)}` : 'Not connected';
|
||||
}
|
||||
|
||||
function toggleBulbPower(dev){
|
||||
const target = dev.state.on ? 'off' : 'on';
|
||||
const ok = publish(dev, 'command', target);
|
||||
byId(dev.ids.feedback).textContent = ok ? `Power ${target}` : 'Not connected';
|
||||
}
|
||||
|
||||
function updatePlugUI(plug){
|
||||
const wrap = byId(plug.els.stateWrap); const text = byId(plug.els.stateText);
|
||||
wrap.classList.toggle('on', plug.state.on); wrap.classList.toggle('off', !plug.state.on);
|
||||
text.textContent = plug.state.on ? 'on' : 'off';
|
||||
}
|
||||
|
||||
function plugOn(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:true } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → on' : 'Not connected'; }
|
||||
function plugOff(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:false } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → off' : 'Not connected'; }
|
||||
function plugToggle(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Toggle', params:{ id: plug.switchId } }); byId(plug.els.fb).textContent = ok ? 'Switch.Toggle' : 'Not connected'; }
|
||||
|
||||
(function initOfficeLight(){
|
||||
const d = devices[0]; const ids = d.ids; const apply = debounce(() => sendBulbUpdate(d), 180);
|
||||
byId(ids.modeSeg).querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { d.state.mode = btn.dataset.mode; updateBulbUI(d); apply(); }); });
|
||||
byId(ids.R).addEventListener('input', e => { d.state.r = +e.target.value; byId(ids.Rv).textContent = d.state.r; updateBulbUI(d); apply(); });
|
||||
byId(ids.G).addEventListener('input', e => { d.state.g = +e.target.value; byId(ids.Gv).textContent = d.state.g; updateBulbUI(d); apply(); });
|
||||
byId(ids.B).addEventListener('input', e => { d.state.b = +e.target.value; byId(ids.Bv).textContent = d.state.b; updateBulbUI(d); apply(); });
|
||||
byId(ids.W).addEventListener('input', e => { d.state.w = +e.target.value; byId(ids.Wv).textContent = d.state.w; updateBulbUI(d); apply(); });
|
||||
byId(ids.bright).addEventListener('input', e => { d.state.gain = +e.target.value; byId(ids.brightv).textContent = d.state.gain; updateBulbUI(d); apply(); });
|
||||
byId(ids.effect).addEventListener('input', e => { d.state.effect = +e.target.value; byId(ids.effectv).textContent = d.state.effect; updateBulbUI(d); apply(); });
|
||||
byId(ids.wbright).addEventListener('input', e => { d.state.brightness = +e.target.value; byId(ids.wbrightv).textContent = d.state.brightness; updateBulbUI(d); apply(); });
|
||||
byId(ids.temp).addEventListener('input', e => { d.state.temp = +e.target.value; byId(ids.tempv).textContent = d.state.temp; updateBulbUI(d); apply(); });
|
||||
byId(ids.effectw).addEventListener('input', e => { d.state.effectw = +e.target.value; byId(ids.effectwv).textContent = d.state.effectw; updateBulbUI(d); apply(); });
|
||||
byId(ids.toggle).addEventListener('click', () => { toggleBulbPower(d); }); updateBulbUI(d);
|
||||
})();
|
||||
|
||||
(function initPlugs(){
|
||||
[{id:'office'}, {id:'living'}].forEach(k=>{}); // noop placeholder to keep structure tidy
|
||||
devices.filter(d=>d.kind==='plug').forEach(plug => {
|
||||
byId(plug.els.on).addEventListener('click', ()=>plugOn(plug));
|
||||
byId(plug.els.off).addEventListener('click', ()=>plugOff(plug));
|
||||
byId(plug.els.toggle).addEventListener('click', ()=>plugToggle(plug));
|
||||
updatePlugUI(plug);
|
||||
});
|
||||
})();
|
||||
|
||||
elConnect.addEventListener('click', connectMQTT);
|
||||
elDisconnect.addEventListener('click', () => { if (client) { try { client.end(true); } catch(e){} } setConnStatus('disconnected'); });
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const {auto} = storage.get();
|
||||
elAuto.checked = auto; elAutoBadge.classList.toggle('hidden', !auto);
|
||||
if (auto) { try { connectMQTT(); } catch (e) { console.warn('Auto-connect failed', e); showError('Auto-connect failed: ' + e.message); } }
|
||||
});
|
||||
|
||||
elAuto.addEventListener('change', () => {
|
||||
storage.set({ url: elUrl.value, user: elUser.value, pass: elPass.value, auto: elAuto.checked });
|
||||
elAutoBadge.classList.toggle('hidden', !elAuto.checked);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Server Dashboard</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My Home Server Dashboard</h1>
|
||||
<div class="sub">Server & network controls (LAN/VPN)</div>
|
||||
</header>
|
||||
<div id="top-menu"></div>
|
||||
<script>
|
||||
(async function loadMenu(){
|
||||
try {
|
||||
const r = await fetch('/assets/page-elements/nav.html');
|
||||
const html = await r.text();
|
||||
const mount = document.getElementById('top-menu');
|
||||
// Insert the HTML
|
||||
mount.outerHTML = html;
|
||||
|
||||
// Now highlight the current page link
|
||||
const nav = document.querySelector('.top-menu');
|
||||
if (!nav) return;
|
||||
|
||||
// Normalize both sides (strip trailing index.html and trailing slash)
|
||||
const normalize = (u) => {
|
||||
const url = new URL(u, location.origin);
|
||||
let p = url.pathname.replace(/index\.html$/i,'');
|
||||
if (p.length > 1 && p.endsWith('/')) p = p.slice(0,-1);
|
||||
return url.origin + p;
|
||||
};
|
||||
|
||||
const here = normalize(location.href);
|
||||
|
||||
nav.querySelectorAll('a').forEach(a => {
|
||||
const target = normalize(a.href);
|
||||
|
||||
// exact match OR “same origin + same base path” cases
|
||||
const isExact = target === here;
|
||||
const isRootMatch = (
|
||||
target === normalize(location.origin + '/') &&
|
||||
(here === normalize(location.origin + '/') || here === normalize(location.href))
|
||||
);
|
||||
|
||||
if (isExact || isRootMatch) {
|
||||
a.classList.add('is-active');
|
||||
a.setAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn('Top menu load failed:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="container">
|
||||
<div class="grid">
|
||||
<!-- Home Automation link -->
|
||||
<div class="card" id="home-automation">
|
||||
<h2>Home Automation</h2>
|
||||
<div class="row">
|
||||
<a class="btn" href="http://automation.richardjolley.co.uk" target="_blank">Open Home Automation</a>
|
||||
<span class="chip">LAN/VPN only</span>
|
||||
</div>
|
||||
<p class="notice">MQTT device controls.</p>
|
||||
</div>
|
||||
|
||||
<!-- Jellyfin (optional; guarded in JS) -->
|
||||
<div class="card" id="jellyfin-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Jellyfin</h2>
|
||||
<div class="pill" id="jellyfin-pill"><span class="status-dot"></span><span id="jellyfin-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://jellyfin.richardjolley.co.uk" target="_blank">Open Jellyfin</a>
|
||||
<button class="btn ghost small" onclick="restartService('jellyfin')">Restart</button>
|
||||
</div>
|
||||
<div class="feedback" id="jellyfin-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- Pi-hole -->
|
||||
<div class="card" id="pihole-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Pi-hole</h2>
|
||||
<div class="pill" id="pihole-FTL-pill"><span class="status-dot"></span><span id="pihole-FTL-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://pihole.richardjolley.co.uk/admin" target="_blank">Open Pi-hole</a>
|
||||
<button class="btn ghost small" onclick="restartService('pihole-FTL')">Restart</button>
|
||||
</div>
|
||||
<div class="feedback" id="pihole-FTL-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud (Apache) -->
|
||||
<div class="card" id="nextcloud-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Nextcloud</h2>
|
||||
<div class="pill" id="apache2-pill"><span class="status-dot"></span><span id="apache2-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://cloud.richardjolley.co.uk" target="_blank">Open Nextcloud</a>
|
||||
<button class="btn ghost small" onclick="restartService('apache2')">Restart Apache</button>
|
||||
</div>
|
||||
<div class="feedback" id="apache2-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="card" id="filebrowser-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">File Browser</h2>
|
||||
<div class="pill" id="filebrowser-pill"><span class="status-dot"></span><span id="filebrowser-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://filebrowser.richardjolley.co.uk" target="_blank">Open File Browser</a>
|
||||
<button class="btn ghost small" onclick="restartService('filebrowser')">Restart</button>
|
||||
</div>
|
||||
<div class="feedback" id="filebrowser-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- WireGuard Tunnels -->
|
||||
<div class="card" id="wireguard-tunnels-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">WireGuard Tunnels</h2>
|
||||
<span class="notice">Bring interfaces up/down</span>
|
||||
</div>
|
||||
<div id="wg-tunnels" class="section"></div>
|
||||
<div class="feedback ok" id="wg-tunnel-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- VPN (Surfshark) -->
|
||||
<div class="card" id="vpn-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">VPN (Surfshark)</h2>
|
||||
<span class="notice">OpenVPN via CGI</span>
|
||||
</div>
|
||||
<div class="section">
|
||||
<label class="label" for="vpn-location">Location</label>
|
||||
<select id="vpn-location" style="padding:8px; border:1px solid #e5e7eb; border-radius:8px; min-width:180px;">
|
||||
<option value="uk-lon">London</option>
|
||||
<option value="uk-man">Manchester</option>
|
||||
<option value="uk-gla">Glasgow</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px; gap:8px;">
|
||||
<button class="btn small" onclick="connectVPN()">Connect</button>
|
||||
<button class="btn ghost small" onclick="disconnectVPN()">Disconnect</button>
|
||||
<button class="btn ghost small" onclick="checkVPNStatus()">Check IP</button>
|
||||
</div>
|
||||
<div class="feedback ok" id="vpn-feedback"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setStatusPill(idPrefix, statusText) {
|
||||
const pill = document.getElementById(idPrefix + '-pill');
|
||||
const label = document.getElementById(idPrefix + '-status');
|
||||
if (!label || !pill) return; // guard if card not present
|
||||
label.textContent = statusText || 'unknown';
|
||||
pill.classList.remove('ok','bad');
|
||||
if (/active|running|online|enabled/i.test(statusText)) pill.classList.add('ok');
|
||||
else if (/inactive|failed|stopped|error/i.test(statusText)) pill.classList.add('bad');
|
||||
}
|
||||
|
||||
function fetchStatus(serviceName, elementId) {
|
||||
fetch(`/cgi-bin/control_service_status.cgi?service=${serviceName}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const statusText = data.status || 'unknown';
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) el.textContent = statusText; // keep API-compatible
|
||||
setStatusPill(elementId.replace(/-status$/, ''), statusText);
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) el.textContent = 'error';
|
||||
setStatusPill(elementId.replace(/-status$/, ''), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showFeedback(id, msg, ok=true){
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('ok', ok);
|
||||
el.classList.toggle('err', !ok);
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.transition = 'opacity 1s'; el.style.opacity = '0';
|
||||
setTimeout(()=>{ el.style.display='none'; el.style.opacity='1'; }, 1000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function restartService(serviceName) {
|
||||
fetch('/cgi-bin/control_service_restart.cgi', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `service=${encodeURIComponent(serviceName)}`
|
||||
})
|
||||
.then(r => r.text())
|
||||
.then(() => {
|
||||
showFeedback(`${serviceName}-feedback`, `Restarted ${serviceName} successfully`, true);
|
||||
fetchStatus(serviceName, `${serviceName}-status`);
|
||||
})
|
||||
.catch(() => showFeedback(`${serviceName}-feedback`, `Error restarting ${serviceName}`, false));
|
||||
}
|
||||
|
||||
// WireGuard helpers
|
||||
function checkTunnelStatus(tunnel) {
|
||||
fetch(`/cgi-bin/control_service_status.cgi?service=wg-quick@${tunnel}`)
|
||||
.then(r => r.json())
|
||||
.then(data => setStatusPill(`${tunnel}`, data.status || 'unknown'))
|
||||
.catch(() => setStatusPill(`${tunnel}`, 'error'));
|
||||
}
|
||||
|
||||
function manageTunnel(tunnel, action) {
|
||||
fetch('/cgi-bin/control_wireguard_manage.cgi', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `tunnel=${encodeURIComponent(tunnel)}&action=${encodeURIComponent(action)}`
|
||||
})
|
||||
.then(r => r.text())
|
||||
.then(text => { showFeedback('wg-tunnel-feedback', `Tunnel ${tunnel} ${action}ed: ${text}`, true); checkTunnelStatus(tunnel); })
|
||||
.catch(() => showFeedback('wg-tunnel-feedback', `Failed to ${action} ${tunnel}`, false));
|
||||
}
|
||||
|
||||
function loadWireguardTunnels() {
|
||||
fetch('/cgi-bin/control_wireguard_list.cgi')
|
||||
.then(r => r.json())
|
||||
.then(tunnels => {
|
||||
const container = document.getElementById('wg-tunnels');
|
||||
container.innerHTML = '';
|
||||
tunnels.forEach(({ name, description }) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.style.justifyContent = 'space-between';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.marginBottom = '10px';
|
||||
row.innerHTML = `
|
||||
<div class="row" style="gap:8px;">
|
||||
<strong>${name}</strong>
|
||||
<span class="notice">${description || ''}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="pill" id="${name}-pill"><span class="status-dot"></span><span id="${name}-status" class="label">Checking…</span></div>
|
||||
<button class="btn ghost small" onclick="manageTunnel('${name}','up')">Up</button>
|
||||
<button class="btn ghost small" onclick="manageTunnel('${name}','down')">Down</button>
|
||||
</div>`;
|
||||
container.appendChild(row);
|
||||
checkTunnelStatus(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// VPN (Surfshark)
|
||||
function connectVPN(){
|
||||
const location = document.getElementById('vpn-location').value;
|
||||
fetch('/cgi-bin/vpn-connect.cgi', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body:`location=${encodeURIComponent(location)}`
|
||||
})
|
||||
.then(r=>r.text())
|
||||
.then(()=> showFeedback('vpn-feedback', `VPN connected to ${location}`, true))
|
||||
.catch(()=> showFeedback('vpn-feedback', 'Failed to connect VPN.', false));
|
||||
}
|
||||
function disconnectVPN(){
|
||||
fetch('/cgi-bin/vpn-disconnect.cgi', { method:'POST' })
|
||||
.then(()=> showFeedback('vpn-feedback', 'VPN disconnected.', true))
|
||||
.catch(()=> showFeedback('vpn-feedback', 'Failed to disconnect VPN.', false));
|
||||
}
|
||||
function checkVPNStatus(){
|
||||
fetch('/cgi-bin/vpn-status.cgi')
|
||||
.then(r=>r.text())
|
||||
.then(text=> showFeedback('vpn-feedback', `VPN status: ${text}`, true))
|
||||
.catch(()=> showFeedback('vpn-feedback', 'Failed to check VPN status.', false));
|
||||
}
|
||||
|
||||
// Initial status checks (guarded if cards exist)
|
||||
['jellyfin','pihole-FTL','apache2','filebrowser'].forEach(svc => {
|
||||
const el = document.getElementById(`${svc}-status`);
|
||||
if (el) fetchStatus(svc, `${svc}-status`);
|
||||
});
|
||||
|
||||
loadWireguardTunnels();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Server Dashboard</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My Home Server Dashboard</h1>
|
||||
<div class="sub">Server & network controls (LAN/VPN)</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="grid">
|
||||
<!-- Home Automation link -->
|
||||
<div class="card" id="home-automation">
|
||||
<h2>Home Automation</h2>
|
||||
<div class="row">
|
||||
<a class="btn" href="http://automation.richardjolley.co.uk" target="_blank">Open Home Automation</a>
|
||||
<span class="chip">LAN/VPN only</span>
|
||||
</div>
|
||||
<p class="notice">MQTT device controls.</p>
|
||||
</div>
|
||||
|
||||
<!-- Jellyfin (optional; guarded in JS) -->
|
||||
<div class="card" id="jellyfin-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Jellyfin</h2>
|
||||
<div class="pill" id="jellyfin-pill"><span class="status-dot"></span><span id="jellyfin-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://jellyfin.richardjolley.co.uk" target="_blank">Open Jellyfin</a>
|
||||
<button class="btn ghost small" onclick="restartService('jellyfin')">Restart</button>
|
||||
</div>
|
||||
<div class="feedback" id="jellyfin-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- Pi-hole -->
|
||||
<div class="card" id="pihole-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Pi-hole</h2>
|
||||
<div class="pill" id="pihole-FTL-pill"><span class="status-dot"></span><span id="pihole-FTL-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://pihole.richardjolley.co.uk/admin" target="_blank">Open Pi-hole</a>
|
||||
<button class="btn ghost small" onclick="restartService('pihole-FTL')">Restart</button>
|
||||
</div>
|
||||
<div class="feedback" id="pihole-FTL-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud (Apache) -->
|
||||
<div class="card" id="nextcloud-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Nextcloud</h2>
|
||||
<div class="pill" id="apache2-pill"><span class="status-dot"></span><span id="apache2-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://cloud.richardjolley.co.uk" target="_blank">Open Nextcloud</a>
|
||||
<button class="btn ghost small" onclick="restartService('apache2')">Restart Apache</button>
|
||||
</div>
|
||||
<div class="feedback" id="apache2-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="card" id="filebrowser-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">File Browser</h2>
|
||||
<div class="pill" id="filebrowser-pill"><span class="status-dot"></span><span id="filebrowser-status" class="label">Checking…</span></div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://filebrowser.richardjolley.co.uk" target="_blank">Open File Browser</a>
|
||||
<button class="btn ghost small" onclick="restartService('filebrowser')">Restart</button>
|
||||
</div>
|
||||
<div class="feedback" id="filebrowser-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- WireGuard Tunnels -->
|
||||
<div class="card" id="wireguard-tunnels-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">WireGuard Tunnels</h2>
|
||||
<span class="notice">Bring interfaces up/down</span>
|
||||
</div>
|
||||
<div id="wg-tunnels" class="section"></div>
|
||||
<div class="feedback ok" id="wg-tunnel-feedback"></div>
|
||||
</div>
|
||||
|
||||
<!-- VPN (Surfshark) -->
|
||||
<div class="card" id="vpn-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">VPN (Surfshark)</h2>
|
||||
<span class="notice">OpenVPN via CGI</span>
|
||||
</div>
|
||||
<div class="section">
|
||||
<label class="label" for="vpn-location">Location</label>
|
||||
<select id="vpn-location" style="padding:8px; border:1px solid #e5e7eb; border-radius:8px; min-width:180px;">
|
||||
<option value="uk-lon">London</option>
|
||||
<option value="uk-man">Manchester</option>
|
||||
<option value="uk-gla">Glasgow</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px; gap:8px;">
|
||||
<button class="btn small" onclick="connectVPN()">Connect</button>
|
||||
<button class="btn ghost small" onclick="disconnectVPN()">Disconnect</button>
|
||||
<button class="btn ghost small" onclick="checkVPNStatus()">Check IP</button>
|
||||
</div>
|
||||
<div class="feedback ok" id="vpn-feedback"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setStatusPill(idPrefix, statusText) {
|
||||
const pill = document.getElementById(idPrefix + '-pill');
|
||||
const label = document.getElementById(idPrefix + '-status');
|
||||
if (!label || !pill) return; // guard if card not present
|
||||
label.textContent = statusText || 'unknown';
|
||||
pill.classList.remove('ok','bad');
|
||||
if (/active|running|online|enabled/i.test(statusText)) pill.classList.add('ok');
|
||||
else if (/inactive|failed|stopped|error/i.test(statusText)) pill.classList.add('bad');
|
||||
}
|
||||
|
||||
function fetchStatus(serviceName, elementId) {
|
||||
fetch(`/cgi-bin/control_service_status.cgi?service=${serviceName}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const statusText = data.status || 'unknown';
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) el.textContent = statusText; // keep API-compatible
|
||||
setStatusPill(elementId.replace(/-status$/, ''), statusText);
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) el.textContent = 'error';
|
||||
setStatusPill(elementId.replace(/-status$/, ''), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showFeedback(id, msg, ok=true){
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('ok', ok);
|
||||
el.classList.toggle('err', !ok);
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.transition = 'opacity 1s'; el.style.opacity = '0';
|
||||
setTimeout(()=>{ el.style.display='none'; el.style.opacity='1'; }, 1000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function restartService(serviceName) {
|
||||
fetch('/cgi-bin/control_service_restart.cgi', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `service=${encodeURIComponent(serviceName)}`
|
||||
})
|
||||
.then(r => r.text())
|
||||
.then(() => {
|
||||
showFeedback(`${serviceName}-feedback`, `Restarted ${serviceName} successfully`, true);
|
||||
fetchStatus(serviceName, `${serviceName}-status`);
|
||||
})
|
||||
.catch(() => showFeedback(`${serviceName}-feedback`, `Error restarting ${serviceName}`, false));
|
||||
}
|
||||
|
||||
// WireGuard helpers
|
||||
function checkTunnelStatus(tunnel) {
|
||||
fetch(`/cgi-bin/control_service_status.cgi?service=wg-quick@${tunnel}`)
|
||||
.then(r => r.json())
|
||||
.then(data => setStatusPill(`${tunnel}`, data.status || 'unknown'))
|
||||
.catch(() => setStatusPill(`${tunnel}`, 'error'));
|
||||
}
|
||||
|
||||
function manageTunnel(tunnel, action) {
|
||||
fetch('/cgi-bin/control_wireguard_manage.cgi', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `tunnel=${encodeURIComponent(tunnel)}&action=${encodeURIComponent(action)}`
|
||||
})
|
||||
.then(r => r.text())
|
||||
.then(text => { showFeedback('wg-tunnel-feedback', `Tunnel ${tunnel} ${action}ed: ${text}`, true); checkTunnelStatus(tunnel); })
|
||||
.catch(() => showFeedback('wg-tunnel-feedback', `Failed to ${action} ${tunnel}`, false));
|
||||
}
|
||||
|
||||
function loadWireguardTunnels() {
|
||||
fetch('/cgi-bin/control_wireguard_list.cgi')
|
||||
.then(r => r.json())
|
||||
.then(tunnels => {
|
||||
const container = document.getElementById('wg-tunnels');
|
||||
container.innerHTML = '';
|
||||
tunnels.forEach(({ name, description }) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.style.justifyContent = 'space-between';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.marginBottom = '10px';
|
||||
row.innerHTML = `
|
||||
<div class="row" style="gap:8px;">
|
||||
<strong>${name}</strong>
|
||||
<span class="notice">${description || ''}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="pill" id="${name}-pill"><span class="status-dot"></span><span id="${name}-status" class="label">Checking…</span></div>
|
||||
<button class="btn ghost small" onclick="manageTunnel('${name}','up')">Up</button>
|
||||
<button class="btn ghost small" onclick="manageTunnel('${name}','down')">Down</button>
|
||||
</div>`;
|
||||
container.appendChild(row);
|
||||
checkTunnelStatus(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// VPN (Surfshark)
|
||||
function connectVPN(){
|
||||
const location = document.getElementById('vpn-location').value;
|
||||
fetch('/cgi-bin/vpn-connect.cgi', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body:`location=${encodeURIComponent(location)}`
|
||||
})
|
||||
.then(r=>r.text())
|
||||
.then(()=> showFeedback('vpn-feedback', `VPN connected to ${location}`, true))
|
||||
.catch(()=> showFeedback('vpn-feedback', 'Failed to connect VPN.', false));
|
||||
}
|
||||
function disconnectVPN(){
|
||||
fetch('/cgi-bin/vpn-disconnect.cgi', { method:'POST' })
|
||||
.then(()=> showFeedback('vpn-feedback', 'VPN disconnected.', true))
|
||||
.catch(()=> showFeedback('vpn-feedback', 'Failed to disconnect VPN.', false));
|
||||
}
|
||||
function checkVPNStatus(){
|
||||
fetch('/cgi-bin/vpn-status.cgi')
|
||||
.then(r=>r.text())
|
||||
.then(text=> showFeedback('vpn-feedback', `VPN status: ${text}`, true))
|
||||
.catch(()=> showFeedback('vpn-feedback', 'Failed to check VPN status.', false));
|
||||
}
|
||||
|
||||
// Initial status checks (guarded if cards exist)
|
||||
['jellyfin','pihole-FTL','apache2','filebrowser'].forEach(svc => {
|
||||
const el = document.getElementById(`${svc}-status`);
|
||||
if (el) fetchStatus(svc, `${svc}-status`);
|
||||
});
|
||||
|
||||
loadWireguardTunnels();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
//This is code for the modification of rules
|
||||
(function(){
|
||||
const APPLY_URL = 'https://control.richardjolley.co.uk/cgi-bin/automation-rules-apply.cgi';
|
||||
const GET_URL = 'https://control.richardjolley.co.uk/cgi-bin/automation-rules-get.cgi';
|
||||
|
||||
const el = id => document.getElementById(id);
|
||||
const modeEl = el('mode');
|
||||
const nameEl = el('rule-name');
|
||||
const idEl = el('match-id');
|
||||
const stEl = el('match-state');
|
||||
const cdEl = el('cooldown');
|
||||
const typeEl = el('action-type');
|
||||
const topicEl= el('action-topic');
|
||||
const payloadEl = el('action-payload');
|
||||
const deviceEl = el('action-device');
|
||||
const statusEl = el('rule-status');
|
||||
|
||||
function setStatus(msg, ok=true) {
|
||||
statusEl.textContent = msg || '';
|
||||
statusEl.style.color = ok ? 'inherit' : 'crimson';
|
||||
}
|
||||
|
||||
function showActionFields() {
|
||||
const t = typeEl.value;
|
||||
document.querySelectorAll('#rule-editor-card .action-row').forEach(row => {
|
||||
const forList = (row.getAttribute('data-for') || '').split(/\s+/);
|
||||
row.style.display = forList.includes(t) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
typeEl.addEventListener('change', showActionFields);
|
||||
showActionFields();
|
||||
|
||||
// Presets
|
||||
document.querySelectorAll('#rule-editor-card .preset').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.getAttribute('data-preset');
|
||||
// Common defaults
|
||||
nameEl.value = '';
|
||||
idEl.value = 0;
|
||||
stEl.value = '1';
|
||||
cdEl.value = 200;
|
||||
|
||||
if (p === 'plug-toggle') {
|
||||
nameEl.value = 's2_toggle_plug_on_press';
|
||||
idEl.value = 1;
|
||||
typeEl.value = 'mqtt_publish';
|
||||
topicEl.value = 'office-plug-lamp/rpc';
|
||||
payloadEl.value = JSON.stringify({ method: "Switch.Toggle", params: { id: 0 } }, null, 2);
|
||||
} else if (p === 'bulb-toggle-legacy') {
|
||||
nameEl.value = 's1_toggle_legacy_bulb';
|
||||
idEl.value = 0;
|
||||
typeEl.value = 'mqtt_publish_toggle_legacy';
|
||||
deviceEl.value = 'office-bulb';
|
||||
} else if (p === 'bulb-brightness-up') {
|
||||
nameEl.value = 's1_brightness_up';
|
||||
idEl.value = 0;
|
||||
typeEl.value = 'mqtt_publish_json';
|
||||
// Adjust to your device’s topic. For legacy Gen1 bulbs, often: shellies/<dev>/color/0/set
|
||||
topicEl.value = 'shellies/office-bulb/color/0/set';
|
||||
payloadEl.value = JSON.stringify({ brightness_change: +10 }, null, 2);
|
||||
} else if (p === 'bulb-brightness-down') {
|
||||
nameEl.value = 's1_brightness_down';
|
||||
idEl.value = 0;
|
||||
typeEl.value = 'mqtt_publish_json';
|
||||
topicEl.value = 'shellies/office-bulb/color/0/set';
|
||||
payloadEl.value = JSON.stringify({ brightness_change: -10 }, null, 2);
|
||||
}
|
||||
showActionFields();
|
||||
});
|
||||
});
|
||||
|
||||
// Submit
|
||||
el('rule-form').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
setStatus('Applying…');
|
||||
|
||||
// Build rule
|
||||
const rule = {
|
||||
name: String(nameEl.value || '').trim(),
|
||||
match: { method: "NotifyInput", params: { id: Number(idEl.value), state: Number(stEl.value) } },
|
||||
actions: [],
|
||||
};
|
||||
const cd = Number(cdEl.value);
|
||||
if (!isNaN(cd) && cd > 0) rule.cooldown_ms = cd;
|
||||
|
||||
const t = typeEl.value;
|
||||
if (t === 'mqtt_publish' || t === 'mqtt_publish_json') {
|
||||
const topic = String(topicEl.value || '').trim();
|
||||
if (!topic) { setStatus('Topic is required for mqtt_publish*', false); return; }
|
||||
let payload = payloadEl.value.trim();
|
||||
let payloadVal = null;
|
||||
|
||||
// For mqtt_publish_json we require valid JSON
|
||||
if (t === 'mqtt_publish_json') {
|
||||
try { payloadVal = payload ? JSON.parse(payload) : null; }
|
||||
catch (e) { setStatus('Payload must be valid JSON', false); return; }
|
||||
rule.actions.push({ type: t, topic, payload: payloadVal });
|
||||
} else {
|
||||
// allow raw strings OR JSON (we won’t parse)
|
||||
try {
|
||||
payloadVal = payload ? JSON.parse(payload) : null;
|
||||
} catch { payloadVal = payload; } // keep as string
|
||||
rule.actions.push({ type: t, topic, payload: payloadVal });
|
||||
}
|
||||
} else if (t === 'mqtt_publish_toggle_legacy') {
|
||||
const device = String(deviceEl.value || '').trim();
|
||||
if (!device) { setStatus('Legacy device name is required', false); return; }
|
||||
rule.actions.push({ type: t, device });
|
||||
} else {
|
||||
setStatus('Unsupported action type', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadOut = (modeEl.value === 'replace')
|
||||
? { mode: 'replace', rules: [ rule ] } // start simple: replace with one rule
|
||||
: { mode: 'append', rule };
|
||||
|
||||
try {
|
||||
const res = await fetch(APPLY_URL, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payloadOut)
|
||||
});
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
const text = await res.text();
|
||||
if (!ct.includes('application/json')) throw new Error(`Expected JSON, got ${ct}: ${text}`);
|
||||
const data = JSON.parse(text);
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setStatus(`Applied ✔ (backup: ${data.backup})`);
|
||||
// Refresh the read-only card if you kept my function name; else call your own reload
|
||||
try { if (typeof loadBridgeRules === 'function') await loadBridgeRules(); } catch {}
|
||||
} catch (e) {
|
||||
setStatus(`Failed: ${e.message}`, false);
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Services</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My Services</h1>
|
||||
<div class="sub">Apps running on the home server</div>
|
||||
</header>
|
||||
<div id="top-menu"></div>
|
||||
<script>
|
||||
(async function loadMenu(){
|
||||
try {
|
||||
const r = await fetch('/assets/page-elements/nav.html');
|
||||
const html = await r.text();
|
||||
const mount = document.getElementById('top-menu');
|
||||
// Insert the HTML
|
||||
mount.outerHTML = html;
|
||||
|
||||
// Now highlight the current page link
|
||||
const nav = document.querySelector('.top-menu');
|
||||
if (!nav) return;
|
||||
|
||||
// Normalize both sides (strip trailing index.html and trailing slash)
|
||||
const normalize = (u) => {
|
||||
const url = new URL(u, location.origin);
|
||||
let p = url.pathname.replace(/index\.html$/i,'');
|
||||
if (p.length > 1 && p.endsWith('/')) p = p.slice(0,-1);
|
||||
return url.origin + p;
|
||||
};
|
||||
|
||||
const here = normalize(location.href);
|
||||
|
||||
nav.querySelectorAll('a').forEach(a => {
|
||||
const target = normalize(a.href);
|
||||
|
||||
// exact match OR “same origin + same base path” cases
|
||||
const isExact = target === here;
|
||||
const isRootMatch = (
|
||||
target === normalize(location.origin + '/') &&
|
||||
(here === normalize(location.origin + '/') || here === normalize(location.href))
|
||||
);
|
||||
|
||||
if (isExact || isRootMatch) {
|
||||
a.classList.add('is-active');
|
||||
a.setAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn('Top menu load failed:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="container">
|
||||
<div class="grid">
|
||||
<!-- Jellyfin -->
|
||||
<div class="card" id="jellyfin-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Jellyfin</h2>
|
||||
<div class="pill" id="jellyfin-pill">
|
||||
<span class="status-dot"></span>
|
||||
<span id="jellyfin-status" class="label">Checking…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://richflix.co.uk" target="_blank">Open Jellyfin</a>
|
||||
<span class="notice">Media server</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audiobooks -->
|
||||
<div class="card" id="audiobookshelf-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Audiobooks</h2>
|
||||
<div class="pill" id="audiobookshelf-pill">
|
||||
<span class="status-dot"></span>
|
||||
<span id="audiobookshelf-status" class="label">Checking…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://audiobooks.richardjolley.co.uk" target="_blank">Open Audiobooks</a>
|
||||
<span class="notice">Audiobook server</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud (Apache) -->
|
||||
<div class="card" id="nextcloud-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">Nextcloud</h2>
|
||||
<div class="pill" id="apache2-pill">
|
||||
<span class="status-dot"></span>
|
||||
<span id="apache2-status" class="label">Checking…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://cloud.richardjolley.co.uk" target="_blank">Open Nextcloud</a>
|
||||
<span class="notice">Files & collaboration</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="card" id="filebrowser-card">
|
||||
<div class="row" style="justify-content:space-between; align-items:center;">
|
||||
<h2 style="margin:0;">File Browser</h2>
|
||||
<div class="pill" id="filebrowser-pill">
|
||||
<span class="status-dot"></span>
|
||||
<span id="filebrowser-status" class="label">Checking…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row section">
|
||||
<a class="btn" href="http://filebrowser.richardjolley.co.uk" target="_blank">Open File Browser</a>
|
||||
<span class="notice">Web file manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Same pill logic as control page, trimmed to read-only.
|
||||
function setStatusPill(idPrefix, statusText) {
|
||||
const pill = document.getElementById(idPrefix + '-pill');
|
||||
const label = document.getElementById(idPrefix + '-status');
|
||||
if (!label || !pill) return;
|
||||
label.textContent = statusText || 'unknown';
|
||||
pill.classList.remove('ok','bad');
|
||||
if (/active|running|online|enabled/i.test(statusText)) pill.classList.add('ok');
|
||||
else if (/inactive|failed|stopped|error/i.test(statusText)) pill.classList.add('bad');
|
||||
}
|
||||
|
||||
function fetchStatus(serviceName, elementId) {
|
||||
fetch(`/cgi-bin/control_service_status.cgi?service=${serviceName}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const statusText = data.status || 'unknown';
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) el.textContent = statusText;
|
||||
setStatusPill(elementId.replace(/-status$/, ''), statusText);
|
||||
})
|
||||
.catch(() => {
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) el.textContent = 'error';
|
||||
setStatusPill(elementId.replace(/-status$/, ''), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Initial checks (read-only)
|
||||
['jellyfin', 'audiobookshelf','apache2','filebrowser'].forEach(svc => {
|
||||
const el = document.getElementById(`${svc}-status`);
|
||||
if (el) fetchStatus(svc, `${svc}-status`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
:root { --card-bg:#fff; --muted:#6b7280; }
|
||||
|
||||
* { box-sizing:border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
|
||||
margin:0; background:#f3f4f6; color:#111827;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header { padding:24px 16px 8px; text-align:center; }
|
||||
h1 { margin:0 0 6px; font-size: clamp(20px, 3vw, 32px); }
|
||||
.sub { color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* Layout */
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:16px; }
|
||||
|
||||
/* Card */
|
||||
.card { min-width: 0; background: var(--card-bg); border-radius:14px; box-shadow: 0 6px 20px rgba(0,0,0,.06); padding:18px; margin-bottom: 20px; }
|
||||
.card h2 { margin:0 0 12px; font-size:18px; }
|
||||
|
||||
/* Flex helpers */
|
||||
.row { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.spacer { flex:1; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { appearance:none; border:0; border-radius:10px; padding:8px 12px; background:#111827; color:#fff; cursor:pointer; font-weight:600; }
|
||||
.btn.ghost { background:#e5e7eb; color:#111827; }
|
||||
.btn.small { padding:6px 10px; font-size:12px; }
|
||||
|
||||
/* Pills & chips */
|
||||
.pill { border-radius:999px; padding:6px 10px; background:#f3f4f6; color:#111827; display:inline-flex; align-items:center; gap:8px; }
|
||||
.status-dot { width:10px; height:10px; border-radius:50%; background:#9CA3AF; display:inline-block; }
|
||||
.ok .status-dot { background:#10B981; }
|
||||
.bad .status-dot { background:#EF4444; }
|
||||
.chip { font-size:12px; padding:4px 8px; border-radius:999px; background:#eef2ff; color:#3730a3; }
|
||||
|
||||
/* Inputs & notices */
|
||||
.notice { color: var(--muted); font-size:12px; }
|
||||
input[type="text"], input[type="password"], select {
|
||||
padding:8px; border:1px solid #e5e7eb; border-radius:8px;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.hidden { display:none; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
|
||||
/* Optional: color/preview box used on automation page */
|
||||
.color-box { width:36px; height:24px; border-radius:6px; border:1px solid #e5e7eb; }
|
||||
|
||||
/* Segmented control (automation page) */
|
||||
.seg { display:inline-flex; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden; }
|
||||
.seg button { background:#fff; color:#111; border:0; padding:6px 10px; }
|
||||
.seg button.active { background:#111827; color:#fff; }
|
||||
|
||||
.top-menu {
|
||||
background:#111827;
|
||||
padding:10px 20px;
|
||||
}
|
||||
.top-menu ul {
|
||||
list-style:none;
|
||||
margin:0;
|
||||
padding:0;
|
||||
display:flex;
|
||||
gap:20px;
|
||||
}
|
||||
.top-menu a {
|
||||
color:white;
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
font-size:14px;
|
||||
}
|
||||
.top-menu a:hover {
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
/* Top menu wrapper */
|
||||
.top-menu { padding: 8px 0; }
|
||||
|
||||
/* Center the buttons and add even spacing */
|
||||
.top-menu__row { justify-content: center; gap: 8px; }
|
||||
|
||||
/* Active page: make the ghost button look “primary” */
|
||||
.btn.ghost.is-active {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
outline: 2px solid #111827; /* subtle emphasis */
|
||||
}
|
||||
|
||||
/* Keyboard focus ring (accessible) */
|
||||
.top-menu a.btn:focus-visible {
|
||||
outline: 2px solid #111827;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<nav class="top-menu" aria-label="Site">
|
||||
<div class="container">
|
||||
<div class="row top-menu__row">
|
||||
<a class="btn ghost small" href="http://control.richardjolley.co.uk/">Control</a>
|
||||
<a class="btn ghost small" href="http://automation.richardjolley.co.uk/">Automation</a>
|
||||
<a class="btn ghost small" href="http://services.richardjolley.co.uk/">Services</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('home.marketing'); // or 'home.student' later
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Eluceo\iCal\Domain\ValueObject\UniqueIdentifier;
|
||||
use Eluceo\iCal\Domain\Entity\Calendar;
|
||||
use Eluceo\iCal\Domain\ValueObject\TimeSpan;
|
||||
use Eluceo\iCal\Domain\Entity\Event as IcalEvent;
|
||||
use Eluceo\iCal\Domain\Entity\Event\Occurrence as IcalOccurrence;
|
||||
use Eluceo\iCal\Domain\ValueObject\DateTime;
|
||||
use Eluceo\iCal\Presentation\Factory\CalendarFactory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\TeacherProfile;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class IcsFeedController extends Controller
|
||||
{
|
||||
public function teacher(string $token)
|
||||
{
|
||||
$teacher = TeacherProfile::where('ics_token', $token)->firstOrFail();
|
||||
|
||||
$bookings = DB::table('bookings')
|
||||
->whereBetween('date', [now()->subDay()->toDateString(), now()->addYear()->toDateString()])
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
foreach ($bookings as $b) {
|
||||
$slot = DB::table('slots')->where('id', $b->slot_id)->first();
|
||||
if (!$slot) { continue; } // (optional guard)
|
||||
|
||||
$startAt = Carbon::parse($b->date.' '.$slot->time, 'Europe/London')->utc();
|
||||
$endAt = (clone $startAt)->addMinutes(60);
|
||||
|
||||
$uid = 'booking-'.$b->id.'@tutoring.richardjolley.co.uk';
|
||||
|
||||
// IMPORTANT: build UTC DateTime objects that will serialize as ...Z (no TZID)
|
||||
$start = new DateTime(new \DateTimeImmutable($startAt->format('Y-m-d\TH:i:s')), true);
|
||||
$end = new DateTime(new \DateTimeImmutable($endAt->format('Y-m-d\TH:i:s')), true);
|
||||
|
||||
$event = (new IcalEvent(new UniqueIdentifier($uid)))
|
||||
->setSummary('Tutoring Booking')
|
||||
->setOccurrence(new TimeSpan($start, $end));
|
||||
|
||||
$events[] = $event;
|
||||
}
|
||||
|
||||
$calendar = new Calendar($events);
|
||||
$ics = (new CalendarFactory())->createCalendar($calendar);
|
||||
|
||||
return response($ics)
|
||||
->header('Content-Type', 'text/calendar; charset=utf-8')
|
||||
->header('Content-Disposition', 'attachment; filename="tutoring-'.$teacher->id.'.ics"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Level;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LevelController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$levels = Level::all();
|
||||
return view('admin.levels.index', compact('levels'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('admin.levels.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:levels,name',
|
||||
]);
|
||||
|
||||
Level::create(['name' => $request->name]);
|
||||
|
||||
return redirect()->route('admin.levels.index')->with('success', 'Level added successfully!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Slot;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\AvailabilityService;
|
||||
|
||||
class BookingController extends Controller
|
||||
{
|
||||
public function create(Request $request)
|
||||
{
|
||||
// Expect: /student/bookings/create?date=YYYY-MM-DD&slot_id=NN
|
||||
$data = $request->validate([
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
]);
|
||||
|
||||
$slot = Slot::findOrFail($data['slot_id']);
|
||||
|
||||
// Prettify labels so Blade doesn’t need Carbon
|
||||
$when = Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$prettyDate = $when->format('D d M Y');
|
||||
$prettyTime = $when->format('H:i');
|
||||
$weekdayName = $when->format('l');
|
||||
|
||||
return view('student.bookings.create', [
|
||||
'slot' => $slot,
|
||||
'date' => $data['date'],
|
||||
'prettyDate' => $prettyDate,
|
||||
'prettyTime' => $prettyTime,
|
||||
'weekdayName' => $weekdayName,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createSeries(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Ensure these use's exist at the top of the file:
|
||||
// use App\Models\Slot;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
// Expect: /student/bookings/series/create?date=YYYY-MM-DD&slot_id=NN&weeks=6
|
||||
$data = $request->validate([
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'weeks' => ['nullable','integer','min:1','max:52'],
|
||||
]);
|
||||
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
|
||||
// Prettify labels (keep Blade Carbon-free)
|
||||
$when = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$prettyDate = $when->format('D d M Y');
|
||||
$prettyTime = $when->format('H:i');
|
||||
$weekdayName = $when->format('l');
|
||||
|
||||
$weeks = $data['weeks'] ?? 6;
|
||||
|
||||
return view('student.bookings.series', [
|
||||
'slot' => $slot,
|
||||
'date' => $data['date'],
|
||||
'prettyDate' => $prettyDate,
|
||||
'prettyTime' => $prettyTime,
|
||||
'weekdayName' => $weekdayName,
|
||||
'weeks' => $weeks,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Ensure these use's exist at the top of the file:
|
||||
// use App\Models\Slot;
|
||||
// use App\Models\Booking;
|
||||
// use Carbon\Carbon;
|
||||
// use Illuminate\Validation\ValidationException;
|
||||
// use Illuminate\Database\QueryException;
|
||||
|
||||
$data = $request->validate([
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'date' => ['required','date_format:Y-m-d'],
|
||||
'message' => ['nullable','string'],
|
||||
]);
|
||||
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
|
||||
// Enforce > 2 hours’ notice (exactly 120 mins = NOT allowed)
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$sessionStart = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
if ($minutesUntil <= 120) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'date' => 'Bookings require more than 2 hours’ notice.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Pull student name from the logged-in user
|
||||
$user = $request->user();
|
||||
$studentName = $user?->name ?? 'Student';
|
||||
|
||||
try {
|
||||
\App\Models\Booking::create([
|
||||
'slot_id' => $slot->id,
|
||||
'date' => $data['date'],
|
||||
'student_name' => $studentName,
|
||||
'booked_from_page' => 'student:booking',
|
||||
'message' => $data['message'] ?? null,
|
||||
'status' => 'booked',
|
||||
]);
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Unique (slot_id, date) hit — slot already taken
|
||||
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'slot_id' => 'Sorry, that slot was just taken. Please choose another.',
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('student.calendar')
|
||||
->with('success', 'Your booking is confirmed.');
|
||||
}
|
||||
|
||||
public function storeSeries(\Illuminate\Http\Request $request)
|
||||
{
|
||||
// Validates: slot, start date, and weeks count
|
||||
$data = $request->validate([
|
||||
'slot_id' => ['required','exists:slots,id'],
|
||||
'date' => ['required','date_format:Y-m-d'], // start date
|
||||
'weeks' => ['required','integer','min:1','max:52'],
|
||||
]);
|
||||
|
||||
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$startDate = \Carbon\Carbon::createFromFormat('Y-m-d', $data['date'], 'Europe/London');
|
||||
$user = $request->user();
|
||||
$student = $user?->name ?? 'Student';
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
for ($i = 0; $i < (int)$data['weeks']; $i++) {
|
||||
$date = $startDate->copy()->addWeeks($i)->toDateString();
|
||||
$sessionStart = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$slot->time}", 'Europe/London');
|
||||
|
||||
// Enforce > 2 hours’ notice (exactly 120 mins = NOT allowed)
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
if ($minutesUntil <= 120) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
\App\Models\Booking::create([
|
||||
'slot_id' => $slot->id,
|
||||
'date' => $date,
|
||||
'student_name' => $student,
|
||||
'booked_from_page' => 'student:block-series',
|
||||
'message' => null,
|
||||
'status' => 'booked',
|
||||
]);
|
||||
$created++;
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Skip duplicates (unique (slot_id, date))
|
||||
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('student.calendar')
|
||||
->with('success', "Series created: {$created} added, {$skipped} skipped.");
|
||||
}
|
||||
|
||||
public function cancelConfirm(\App\Models\Booking $booking)
|
||||
{
|
||||
// Ensure these use's exist at the top:
|
||||
// use App\Models\Booking;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
$user = auth()->user();
|
||||
$studentName = $user?->name ?? '';
|
||||
|
||||
// Authorize: must be this student's booking and still "booked"
|
||||
if ($booking->student_name !== $studentName || $booking->status !== 'booked') {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Need the slot's time to compute the session start
|
||||
$booking->load('slot');
|
||||
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$sessionStart = \Carbon\Carbon::createFromFormat(
|
||||
'Y-m-d H:i:s',
|
||||
$booking->date.' '.$booking->slot->time,
|
||||
'Europe/London'
|
||||
);
|
||||
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
|
||||
// If already started or in the past, disallow cancellation
|
||||
if ($minutesUntil <= 0) {
|
||||
return redirect()
|
||||
->route('student.bookings.index')
|
||||
->withErrors(['booking' => 'This session has started or is in the past and can’t be cancelled.']);
|
||||
}
|
||||
|
||||
// Refund policy band (for display only; payments added later)
|
||||
// ≥ 24h => 100%; < 24h => 50%
|
||||
$refundPercent = ($minutesUntil >= 24 * 60) ? 100 : 50;
|
||||
|
||||
// Prettified labels so Blade stays Carbon-free
|
||||
$prettyDate = $sessionStart->format('D d M Y');
|
||||
$prettyTime = $sessionStart->format('H:i');
|
||||
$weekdayName = $sessionStart->format('l');
|
||||
|
||||
return view('student.bookings.cancel', [
|
||||
'booking' => $booking,
|
||||
'prettyDate' => $prettyDate,
|
||||
'prettyTime' => $prettyTime,
|
||||
'weekdayName' => $weekdayName,
|
||||
'refundPercent' => $refundPercent,
|
||||
'minutesUntil' => $minutesUntil,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancelStore(\App\Models\Booking $booking)
|
||||
{
|
||||
// Ensure these use's exist at the top:
|
||||
// use App\Models\Booking;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
$user = auth()->user();
|
||||
$studentName = $user?->name ?? '';
|
||||
|
||||
// Authorize: must be this student's booking and still "booked"
|
||||
if ($booking->student_name !== $studentName || $booking->status !== 'booked') {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Need slot time to compute the session start
|
||||
$booking->load('slot');
|
||||
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$sessionStart = \Carbon\Carbon::createFromFormat(
|
||||
'Y-m-d H:i:s',
|
||||
$booking->date.' '.$booking->slot->time,
|
||||
'Europe/London'
|
||||
);
|
||||
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
|
||||
// If already started or in the past, disallow cancellation
|
||||
if ($minutesUntil <= 0) {
|
||||
return redirect()
|
||||
->route('student.bookings.index')
|
||||
->withErrors(['booking' => 'This session has started or is in the past and can’t be cancelled.']);
|
||||
}
|
||||
|
||||
// Refund band (for info only; payments to be integrated later)
|
||||
$refundPercent = ($minutesUntil >= 24 * 60) ? 100 : 50;
|
||||
|
||||
// For now, delete the booking to free the slot.
|
||||
// (Later we can switch to a "cancelled" status or soft deletes if you want a history.)
|
||||
$booking->delete();
|
||||
|
||||
return redirect()
|
||||
->route('student.bookings.index')
|
||||
->with('success', "Booking cancelled. Refund: {$refundPercent}%.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BookingsListController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Ensure these use's exist at the top of the file:
|
||||
// use App\Models\Booking;
|
||||
// use Carbon\Carbon;
|
||||
|
||||
$user = auth()->user();
|
||||
$name = $user?->name ?? '';
|
||||
$now = \Carbon\Carbon::now('Europe/London');
|
||||
$today = $now->toDateString();
|
||||
|
||||
// Upcoming “booked” sessions for this student (names-based for now)
|
||||
$raw = \App\Models\Booking::with('slot')
|
||||
->where('student_name', $name)
|
||||
->where('status', 'booked')
|
||||
->whereDate('date', '>=', $today)
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Pre-format labels so Blade stays Carbon-free
|
||||
$bookings = $raw->map(function ($b) use ($now) {
|
||||
$when = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', $b->date.' '.$b->slot->time, $now->timezone);
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'date' => $b->date,
|
||||
'prettyDate' => $when->format('D d M Y'),
|
||||
'prettyTime' => $when->format('H:i'),
|
||||
'weekday' => $when->format('l'),
|
||||
'status' => $b->status,
|
||||
'slot_id' => $b->slot_id,
|
||||
];
|
||||
});
|
||||
|
||||
return view('student.bookings.index', [
|
||||
'bookings' => $bookings,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Slot;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CalendarController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$now = Carbon::now('Europe/London');
|
||||
|
||||
// Default: this Mon–Sun, but flip to next week after Fri 15:00
|
||||
$start = $now->copy()->startOfWeek(Carbon::MONDAY);
|
||||
$fridayCutoff = $start->copy()->addDays(4)->setTime(15, 0);
|
||||
if ($now->greaterThanOrEqualTo($fridayCutoff)) {
|
||||
$start->addWeek();
|
||||
}
|
||||
|
||||
// Week navigation
|
||||
$weekOffset = (int) $request->query('week', 0);
|
||||
if ($weekOffset !== 0) {
|
||||
$start->addWeeks($weekOffset);
|
||||
}
|
||||
$end = $start->copy()->addDays(6);
|
||||
|
||||
// Load slots + bookings for this visible week
|
||||
$slots = Slot::with(['bookings' => function ($q) use ($start, $end) {
|
||||
$q->whereBetween('date', [$start->toDateString(), $end->toDateString()]);
|
||||
}])->get();
|
||||
|
||||
// Reuse the same view for now (we'll hide admin-only bits next)
|
||||
return view('calendar.index', [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'weekOffset' => $weekOffset,
|
||||
'slots' => $slots,
|
||||
'now' => $now,
|
||||
'viewer' => 'student',
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CourseController extends Controller
|
||||
{
|
||||
public function show(Course $course)
|
||||
{
|
||||
// Load relationships so Blade can access subject, level, modules etc.
|
||||
$course->load('subject', 'level', 'modules.lessonSections.chapters.lessons');
|
||||
|
||||
return view('student.courses.show', compact('course'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Course;
|
||||
use App\Models\Lesson;
|
||||
use App\Models\LessonProgress;
|
||||
use App\Models\User;
|
||||
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 1) Enrolled courses (with subject & level)
|
||||
$enrolled = $user->courses()
|
||||
->with(['subject', 'level'])
|
||||
->get();
|
||||
|
||||
// 2) Courses not yet enrolled
|
||||
$availableCourses = Course::with(['subject', 'level'])
|
||||
->whereNotIn('id', $enrolled->pluck('id'))
|
||||
->get();
|
||||
|
||||
// 3) Find which courses this user has started (based on LessonProgress)
|
||||
$progressEntries = LessonProgress::where('user_id', $user->id)
|
||||
->with('lesson.chapter.lessonSection.module.course')
|
||||
->orderByDesc('last_viewed_at')
|
||||
->get();
|
||||
|
||||
$startedCourseIds = $progressEntries
|
||||
->pluck('lesson.chapter.lessonSection.module.course.id')
|
||||
->unique();
|
||||
|
||||
// Split enrolled courses into inProgress and notStarted
|
||||
[$inProgressCourses, $notStartedCourses] = $enrolled->partition(
|
||||
fn($course) => $startedCourseIds->contains($course->id)
|
||||
);
|
||||
|
||||
// 4) Build map of the *latest viewed lesson per course*
|
||||
$lastLessonsByCourse = collect();
|
||||
|
||||
foreach ($progressEntries as $entry) {
|
||||
$course = $entry->lesson->chapter->lessonSection->module->course;
|
||||
$courseId = $course->id;
|
||||
|
||||
// Store the first (i.e. most recent, since entries are sorted DESC) occurrence
|
||||
if (!$lastLessonsByCourse->has($courseId)) {
|
||||
$lastLessonsByCourse->put($courseId, $entry->lesson);
|
||||
}
|
||||
}
|
||||
|
||||
return view('student.dashboard', [
|
||||
'inProgressCourses' => $inProgressCourses,
|
||||
'notStartedCourses' => $notStartedCourses,
|
||||
'availableCourses' => $availableCourses,
|
||||
'lastLessonsByCourse' => $lastLessonsByCourse,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Course;
|
||||
use App\Models\Lesson;
|
||||
use App\Models\LessonProgress;
|
||||
|
||||
class LessonController extends Controller
|
||||
{
|
||||
public function show(Course $course, Lesson $lesson)
|
||||
{
|
||||
if (
|
||||
$lesson->chapter &&
|
||||
$lesson->chapter->lessonSection &&
|
||||
$lesson->chapter->lessonSection->module &&
|
||||
$lesson->chapter->lessonSection->module->course &&
|
||||
$lesson->chapter->lessonSection->module->course->id !== $course->id
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$lesson->load([
|
||||
'chapter.lessonSection.module.course.subject',
|
||||
'chapter.lessonSection.module.course.level',
|
||||
'questions' => fn($q) => $q->where('published', 1)->with('student'),
|
||||
]);
|
||||
|
||||
LessonProgress::updateOrCreate(
|
||||
['user_id' => Auth::id(), 'lesson_id' => $lesson->id],
|
||||
['last_viewed_at' => now()]
|
||||
);
|
||||
|
||||
$course = $lesson->chapter->lessonSection->module->course;
|
||||
|
||||
// Collect all chapters across all lesson sections for this course
|
||||
$chapters = $course->lessonSections()
|
||||
->with(['chapters.lessons'])
|
||||
->get()
|
||||
->pluck('chapters')
|
||||
->flatten();
|
||||
|
||||
return view('student.lessons.show', compact('lesson', 'chapters'));
|
||||
}
|
||||
|
||||
|
||||
// Pretty route: /student/{course}/{lesson}
|
||||
public function showByCourse(Course $course, Lesson $lesson)
|
||||
{
|
||||
|
||||
\Log::info('Entered showByCourse route');
|
||||
dd('Reached controller');
|
||||
dd($course, $lesson);
|
||||
// Ensure the lesson actually belongs to the course
|
||||
$belongs = $lesson->chapter
|
||||
&& $lesson->chapter->lessonSection
|
||||
&& $lesson->chapter->lessonSection->module
|
||||
&& $lesson->chapter->lessonSection->module->course
|
||||
&& $lesson->chapter->lessonSection->module->course->id === $course->id;
|
||||
|
||||
if (!$belongs) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $this->show($lesson); // reuse the existing rendering logic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Student;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\Lesson;
|
||||
use App\Models\Course;
|
||||
use App\Models\LessonQuestion;
|
||||
|
||||
class LessonQuestionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a new question from a student.
|
||||
*/
|
||||
public function store(Request $request, Course $course, Lesson $lesson)
|
||||
{
|
||||
$request->validate([
|
||||
'question' => 'required|string|max:2000',
|
||||
]);
|
||||
|
||||
LessonQuestion::create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'user_id' => Auth::id(),
|
||||
'question' => $request->question,
|
||||
'published' => 0,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Your question has been submitted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional — allow teachers (or admins) to post an answer.
|
||||
*/
|
||||
public function answer(Request $request, LessonQuestion $question)
|
||||
{
|
||||
$request->validate([
|
||||
'answer' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$question->update([
|
||||
'answer' => $request->answer,
|
||||
'answered_by' => Auth::id(),
|
||||
'answered_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Answer posted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Subject;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubjectController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$subjects = Subject::all();
|
||||
return view('admin.subjects.index', compact('subjects'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('admin.subjects.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:subjects,name',
|
||||
]);
|
||||
|
||||
Subject::create(['name' => $request->name]);
|
||||
|
||||
return redirect()->route('admin.subjects.index')->with('success', 'Subject added successfully!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AdminOnly
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user || $user->role !== 'admin') {
|
||||
abort(403, 'Admins only.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ExternalBusyWindow;
|
||||
use App\Models\ExternalCalendar;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Sabre\VObject\Reader;
|
||||
|
||||
class SyncNextcloudBusyJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, Queueable, SerializesModels;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
\Log::info('SyncNextcloudBusyJob running at '.now());
|
||||
// Choose which calendars to sync (you can expand later to many).
|
||||
$calendars = ExternalCalendar::where('is_enabled', true)->get();
|
||||
if ($calendars->isEmpty()) return;
|
||||
|
||||
$windowStart = now()->utc();
|
||||
$windowEnd = now()->addWeeks(6)->utc(); // adjust horizon if needed
|
||||
|
||||
foreach ($calendars as $cal) {
|
||||
try {
|
||||
$resp = Http::timeout(20)->get($cal->url);
|
||||
if (!$resp->ok()) {
|
||||
// log and continue
|
||||
\Log::warning("ICS fetch failed", ['cal' => $cal->id, 'status' => $resp->status()]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$ics = $resp->body();
|
||||
if (!trim($ics)) continue;
|
||||
|
||||
$vcal = Reader::read($ics); // VCalendar
|
||||
|
||||
// Expand recurring events into single instances within window
|
||||
// Mutates the calendar to contain expanded VEVENTs
|
||||
$vcal = $vcal->expand(
|
||||
new \DateTime($windowStart->format('Y-m-d\TH:i:s\Z')),
|
||||
new \DateTime($windowEnd->format('Y-m-d\TH:i:s\Z'))
|
||||
);
|
||||
|
||||
// Remove existing windows in this horizon (simple strategy)
|
||||
ExternalBusyWindow::where('external_calendar_id', $cal->id)
|
||||
->whereBetween('starts_at', [$windowStart, $windowEnd])
|
||||
->delete();
|
||||
|
||||
$rows = [];
|
||||
$nowTs = now();
|
||||
|
||||
// Iterate expanded events
|
||||
foreach ($vcal->select('VEVENT') as $ve) {
|
||||
// Treat TRANSPARENT as free; OPAQUE as busy (default busy)
|
||||
$transp = strtoupper((string)($ve->TRANSP ?? 'OPAQUE'));
|
||||
if ($transp === 'TRANSPARENT') continue;
|
||||
|
||||
// Safely extract start/end in UTC
|
||||
$startDt = $ve->DTSTART->getDateTime(new \DateTimeZone('UTC'));
|
||||
// DTEND may be absent; if so, use DTSTART as a zero-length block (or add a default length)
|
||||
$endDt = isset($ve->DTEND)
|
||||
? $ve->DTEND->getDateTime(new \DateTimeZone('UTC'))
|
||||
: (clone $startDt);
|
||||
|
||||
// Skip malformed ranges
|
||||
if ($endDt <= $startDt) continue;
|
||||
|
||||
// Only keep those overlapping our window
|
||||
$startsAt = CarbonImmutable::instance($startDt);
|
||||
$endsAt = CarbonImmutable::instance($endDt);
|
||||
if ($endsAt <= $windowStart || $startsAt >= $windowEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'external_calendar_id' => $cal->id,
|
||||
'starts_at' => $startsAt->toDateTimeString(),
|
||||
'ends_at' => $endsAt->toDateTimeString(),
|
||||
'transparency'=> 'OPAQUE',
|
||||
'last_seen_at'=> $nowTs->toDateTimeString(),
|
||||
'created_at' => $nowTs->toDateTimeString(),
|
||||
'updated_at' => $nowTs->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
// Bulk insert
|
||||
ExternalBusyWindow::insert($rows);
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error("ICS sync error", ['cal' => $cal->id, 'e' => $e->getMessage()]);
|
||||
// Continue with other calendars
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BillingAccount extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'contact_name',
|
||||
'primary_parent_user_id',
|
||||
'invoice_email',
|
||||
'phone',
|
||||
'address_line1',
|
||||
'address_line2',
|
||||
'town',
|
||||
'postcode',
|
||||
'country',
|
||||
];
|
||||
|
||||
/**
|
||||
* The primary parent user linked to this billing account.
|
||||
*/
|
||||
public function primaryParent()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'primary_parent_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Students billed under this account.
|
||||
*/
|
||||
public function students()
|
||||
{
|
||||
return $this->hasMany(StudentProfile::class, 'billing_account_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'slot_id',
|
||||
'date',
|
||||
'student_name',
|
||||
'booked_from_page',
|
||||
'message',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function slot()
|
||||
{
|
||||
return $this->belongsTo(Slot::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Chapter extends Model
|
||||
{
|
||||
protected $fillable = ['lesson_section_id', 'title', 'order'];
|
||||
|
||||
public function lessonSection()
|
||||
{
|
||||
return $this->belongsTo(LessonSection::class);
|
||||
}
|
||||
|
||||
public function lessons()
|
||||
{
|
||||
return $this->hasMany(Lesson::class)->orderBy('order');
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($chapter) {
|
||||
if (empty($chapter->slug)) {
|
||||
$chapter->slug = Str::slug($chapter->title);
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($chapter) {
|
||||
if ($chapter->isDirty('title')) {
|
||||
$chapter->slug = Str::slug($chapter->title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName()
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Course extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['lead_teacher', 'description', 'subject_id', 'level_id'];
|
||||
|
||||
public function subject()
|
||||
{
|
||||
return $this->belongsTo(Subject::class);
|
||||
}
|
||||
|
||||
public function level()
|
||||
{
|
||||
return $this->belongsTo(Level::class);
|
||||
}
|
||||
|
||||
public function modules()
|
||||
{
|
||||
return $this->hasMany(Module::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function lessonSections()
|
||||
{
|
||||
return $this->hasManyThrough(LessonSection::class, Module::class);
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($course) {
|
||||
$subject = $course->subject?->name ?? 'unknown-subject';
|
||||
$level = $course->level?->name ?? 'unknown-level';
|
||||
$course->slug = \Illuminate\Support\Str::slug($subject . '-' . $level);
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalBusyWindow extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'external_calendar_id', 'starts_at', 'ends_at',
|
||||
'transparency', 'last_seen_at'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function calendar()
|
||||
{
|
||||
return $this->belongsTo(ExternalCalendar::class, 'external_calendar_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalCalendar extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'source', 'url', 'is_enabled'];
|
||||
|
||||
public function busyWindows()
|
||||
{
|
||||
return $this->hasMany(ExternalBusyWindow::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'total',
|
||||
'status',
|
||||
'pdf_path',
|
||||
];
|
||||
|
||||
/**
|
||||
* The student this invoice is for.
|
||||
*/
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StudentProfile::class, 'student_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* All invoice line items associated with this invoice.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvoiceItem::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InvoiceItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'invoice_id',
|
||||
'attendance_id',
|
||||
'description',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'amount',
|
||||
];
|
||||
|
||||
/**
|
||||
* The invoice this line item belongs to.
|
||||
*/
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The attendance record this line item is based on.
|
||||
*/
|
||||
public function attendance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LessonAttendance::class, 'attendance_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Lesson extends Model
|
||||
{
|
||||
protected $fillable = ['chapter_id','lesson_section_id','title','description','order', 'slug'];
|
||||
|
||||
//protected $fillable = ['lesson_section_id', 'title', 'description', 'video_url', 'order'];
|
||||
|
||||
public function lessonSection()
|
||||
{
|
||||
return $this->belongsTo(LessonSection::class);
|
||||
}
|
||||
|
||||
public function chapter()
|
||||
{
|
||||
return $this->belongsTo(Chapter::class);
|
||||
}
|
||||
|
||||
public function videos()
|
||||
{
|
||||
return $this->hasMany(LessonVideo::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function progress()
|
||||
{
|
||||
return $this->hasMany(LessonProgress::class);
|
||||
}
|
||||
|
||||
public function questions()
|
||||
{
|
||||
return $this->hasMany(LessonQuestion::class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class LessonAttendance extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'teaching_arrangement_id',
|
||||
'student_teacher_subject_id',
|
||||
'scheduled_start',
|
||||
'scheduled_end',
|
||||
'actual_start',
|
||||
'actual_end',
|
||||
'week_of_year',
|
||||
'status',
|
||||
'billable',
|
||||
'payable',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* The scheduled arrangement this attendance record belongs to.
|
||||
*/
|
||||
public function arrangement(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeachingArrangement::class, 'teaching_arrangement_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The student–teacher–subject relationship for this attendance.
|
||||
*/
|
||||
public function studentTeacherSubject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StudentTeacherSubject::class, 'student_teacher_subject_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice item generated from this attendance (if billed).
|
||||
*/
|
||||
public function invoiceItem(): HasOne
|
||||
{
|
||||
return $this->hasOne(InvoiceItem::class, 'attendance_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teacher pay record generated from this attendance (if payable).
|
||||
*/
|
||||
public function payItem(): HasOne
|
||||
{
|
||||
return $this->hasOne(TeacherPayItem::class, 'attendance_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LessonProgress extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'lesson_id', 'completed', 'last_viewed_at'];
|
||||
|
||||
public function user() {
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function lesson() {
|
||||
return $this->belongsTo(Lesson::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LessonQuestion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'lesson_id',
|
||||
'user_id',
|
||||
'question',
|
||||
'answer',
|
||||
'answered_by',
|
||||
'answered_at',
|
||||
'published',
|
||||
];
|
||||
|
||||
public function lesson()
|
||||
{
|
||||
return $this->belongsTo(Lesson::class);
|
||||
}
|
||||
|
||||
public function student()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function teacher()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'answered_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LessonSection extends Model
|
||||
{
|
||||
protected $fillable = ['module_id', 'title', 'year', 'order', 'slug'];
|
||||
|
||||
public function module()
|
||||
{
|
||||
return $this->belongsTo(Module::class);
|
||||
}
|
||||
|
||||
public function lessons()
|
||||
{
|
||||
return $this->hasMany(Lesson::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function chapters()
|
||||
{
|
||||
return $this->hasMany(Chapter::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function getRouteKeyName()
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($section) {
|
||||
if (empty($section->slug)) {
|
||||
// Use title + uniqid to avoid collisions
|
||||
$section->slug = Str::slug($section->title . '-' . uniqid());
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($section) {
|
||||
if ($section->isDirty('title')) {
|
||||
// When renaming, regenerate slug based on title + id
|
||||
$section->slug = Str::slug($section->title . '-' . $section->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LessonVideo extends Model
|
||||
{
|
||||
protected $fillable = ['lesson_id', 'title', 'video_url', 'order'];
|
||||
|
||||
public function lesson()
|
||||
{
|
||||
return $this->belongsTo(Lesson::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Level extends Model
|
||||
{
|
||||
protected $fillable = ['name'];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Module extends Model
|
||||
{
|
||||
protected $fillable = ['course_id', 'name', 'order'];
|
||||
|
||||
public function course()
|
||||
{
|
||||
return $this->belongsTo(Course::class);
|
||||
}
|
||||
|
||||
public function lessonSections()
|
||||
{
|
||||
return $this->hasMany(LessonSection::class)->orderBy('order');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ParentProfile extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'phone',
|
||||
'address_line1',
|
||||
'address_line2',
|
||||
'town',
|
||||
'postcode',
|
||||
'country',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* Link back to the user account for this parent.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: link to billing accounts where this parent is primary.
|
||||
*/
|
||||
public function billingAccounts()
|
||||
{
|
||||
return $this->hasMany(BillingAccount::class, 'primary_parent_user_id', 'user_id');
|
||||
}
|
||||
|
||||
|
||||
public function students(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
StudentProfile::class,
|
||||
'parent_student',
|
||||
'parent_profile_id', // this model's FK on the pivot
|
||||
'student_profile_id' // related model's FK on the pivot
|
||||
)
|
||||
->withPivot('relationship', 'notes')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function parentProfile(): HasOne
|
||||
{
|
||||
return $this->hasOne(ParentProfile::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Slot extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['weekday', 'time', 'is_active'];
|
||||
|
||||
public function bookings()
|
||||
{
|
||||
return $this->hasMany(Booking::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\ParentProfile;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class StudentProfile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'date_of_birth',
|
||||
'notes',
|
||||
'sen_notes',
|
||||
'year_group',
|
||||
'has_dyslexia',
|
||||
'has_dyspraxia',
|
||||
'has_autism',
|
||||
'has_adhd',
|
||||
'has_speech_needs',
|
||||
'student_type_id',
|
||||
'billing_account_id', // will exist after next migration
|
||||
];
|
||||
|
||||
|
||||
protected $casts = [
|
||||
'date_of_birth' => 'date',
|
||||
];
|
||||
|
||||
/**
|
||||
* The user account for this student
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent/guardian of this student
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function parents(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
ParentProfile::class,
|
||||
'parent_student',
|
||||
'student_profile_id', // FK column pointing to THIS model
|
||||
'parent_profile_id' // FK column pointing to ParentProfile
|
||||
)
|
||||
->withPivot('relationship', 'notes')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subjects (with teachers) that this student studies
|
||||
*/
|
||||
public function subjects(): HasMany
|
||||
{
|
||||
return $this->hasMany(StudentTeacherSubject::class, 'student_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance records for this student
|
||||
*/
|
||||
public function attendance(): HasMany
|
||||
{
|
||||
return $this->hasMany(LessonAttendance::class, 'student_teacher_subject_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of student (online, centre, hybrid, etc)
|
||||
*/
|
||||
public function type(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StudentType::class, 'student_type_id');
|
||||
}
|
||||
|
||||
public function billingAccount()
|
||||
{
|
||||
return $this->belongsTo(BillingAccount::class);
|
||||
}
|
||||
|
||||
public function students()
|
||||
{
|
||||
return $this->belongsToMany(StudentProfile::class, 'parent_student')
|
||||
->withPivot('relationship', 'notes')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class StudentTeacherSubject extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
'teacher_id',
|
||||
'subject_id',
|
||||
'rate_override',
|
||||
'status',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* The student this relationship belongs to.
|
||||
*/
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StudentProfile::class, 'student_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The teacher assigned to this student for this subject.
|
||||
*/
|
||||
public function teacher(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeacherProfile::class, 'teacher_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject being taught.
|
||||
*/
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class, 'subject_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teaching arrangements (sessions/timetabled slots)
|
||||
* linking this student–teacher subject pair to actual classes.
|
||||
*/
|
||||
public function teachingArrangements(): HasMany
|
||||
{
|
||||
return $this->hasMany(TeachingArrangementStudent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance records for this student–teacher–subject combination.
|
||||
*/
|
||||
public function attendance(): HasMany
|
||||
{
|
||||
return $this->hasMany(LessonAttendance::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class StudentType extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Students who belong to this type.
|
||||
*
|
||||
* Assuming student_profiles has a student_type_id column.
|
||||
*/
|
||||
public function students()
|
||||
{
|
||||
return $this->hasMany(StudentProfile::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Subject extends Model
|
||||
{
|
||||
protected $fillable = ['name'];
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($subject) {
|
||||
if (empty($subject->slug)) {
|
||||
$subject->slug = Str::slug($subject->name);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TeacherPayItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'teacher_pay_period_id',
|
||||
'attendance_id',
|
||||
'hours',
|
||||
'rate',
|
||||
'total',
|
||||
];
|
||||
|
||||
/**
|
||||
* The pay period this item belongs to.
|
||||
*/
|
||||
public function payPeriod(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeacherPayPeriod::class, 'teacher_pay_period_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The attendance record this payment item is based on.
|
||||
*/
|
||||
public function attendance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LessonAttendance::class, 'attendance_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TeacherPayPeriod extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'teacher_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'total',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* The teacher this pay period belongs to.
|
||||
*/
|
||||
public function teacher(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeacherProfile::class, 'teacher_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Each payable lesson included in this period.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(TeacherPayItem::class, 'teacher_pay_period_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TeacherProfile extends Model
|
||||
{
|
||||
// Which attributes can be mass-assigned (used in ::create([...]))
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'bio',
|
||||
'picture',
|
||||
'location',
|
||||
'hourly_rate',
|
||||
'qualifications',
|
||||
'active',
|
||||
];
|
||||
|
||||
/**
|
||||
* Link back to the user this profile belongs to
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subjects this teacher teaches
|
||||
*/
|
||||
public function subjects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Subject::class, 'level_subject_teacher')
|
||||
->withPivot('level_id'); // since we’re also tracking level
|
||||
}
|
||||
|
||||
/**
|
||||
* Levels this teacher teaches
|
||||
*/
|
||||
public function levels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Level::class, 'level_subject_teacher')
|
||||
->withPivot('subject_id');
|
||||
}
|
||||
|
||||
public function teachingArrangements(): HasMany
|
||||
{
|
||||
return $this->hasMany(TeachingArrangement::class, 'teacher_id');
|
||||
}
|
||||
|
||||
public function payPeriods(): HasMany
|
||||
{
|
||||
return $this->hasMany(TeacherPayPeriod::class, 'teacher_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TeachingArrangement extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'teacher_id',
|
||||
'subject_id',
|
||||
'weekday',
|
||||
'start_time',
|
||||
'duration_minutes',
|
||||
'is_group',
|
||||
'max_students',
|
||||
'location_id',
|
||||
'fee_policy',
|
||||
'active',
|
||||
];
|
||||
|
||||
/**
|
||||
* The teacher running this arrangement.
|
||||
*/
|
||||
public function teacher(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeacherProfile::class, 'teacher_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject being taught.
|
||||
*/
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class, 'subject_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the session takes place (room, online, etc.)
|
||||
*/
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Location::class, 'location_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Students assigned to this arrangement.
|
||||
*/
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasMany(TeachingArrangementStudent::class, 'teaching_arrangement_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance records generated from this arrangement.
|
||||
*/
|
||||
public function attendance(): HasMany
|
||||
{
|
||||
return $this->hasMany(LessonAttendance::class, 'teaching_arrangement_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TeachingArrangementStudent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'teaching_arrangement_id',
|
||||
'student_teacher_subject_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The teaching arrangement (scheduled slot).
|
||||
*/
|
||||
public function arrangement(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeachingArrangement::class, 'teaching_arrangement_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The student–teacher–subject relationship linked to this slot.
|
||||
*/
|
||||
public function studentTeacherSubject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StudentTeacherSubject::class, 'student_teacher_subject_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasRoles {
|
||||
HasRoles::hasRole as spatieHasRole;
|
||||
}
|
||||
|
||||
|
||||
public function teacherProfile()
|
||||
{
|
||||
return $this->hasOne(TeacherProfile::class);
|
||||
}
|
||||
|
||||
public function studentProfile()
|
||||
{
|
||||
return $this->hasOne(StudentProfile::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function lessonProgress()
|
||||
{
|
||||
return $this->hasMany(LessonProgress::class);
|
||||
}
|
||||
|
||||
public function courses()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Course::class, 'course_user')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function hasRole($roles, $guard = null): bool
|
||||
{
|
||||
// God inherits all roles
|
||||
if ($this->roles->pluck('name')->contains('god')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delegate to Spatie's HasRoles trait
|
||||
return $this->spatieHasRole($roles, $guard);
|
||||
}
|
||||
|
||||
public function getNameAttribute()
|
||||
{
|
||||
return trim($this->first_name . ' ' . $this->last_name);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use App\Models\Course;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $policies = [
|
||||
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
|
||||
];
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
// God mode: super-admin can do anything
|
||||
Gate::before(fn($user) => $user->hasRole('super-admin') ? true : null);
|
||||
|
||||
// Example gates
|
||||
Gate::define('view-course', fn($user, Course $course) =>
|
||||
$user->hasAnyRole(['admin', 'teacher'])
|
||||
);
|
||||
|
||||
Gate::define('update-course', fn($user, Course $course) =>
|
||||
$user->hasRole('admin') ||
|
||||
($user->hasRole('teacher') && $course->teacher_id === $user->id)
|
||||
);
|
||||
|
||||
Gate::define('answer-question', fn($user) =>
|
||||
$user->hasAnyRole(['teacher', 'admin'])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||
->pages([
|
||||
Pages\Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([
|
||||
Widgets\AccountWidget::class,
|
||||
Widgets\FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Models\Course;
|
||||
use App\Models\Chapter;
|
||||
use App\Models\Lesson;
|
||||
use App\Models\LessonSection;
|
||||
|
||||
class RouteBindingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Route::bind('course', function ($value) {
|
||||
return \App\Models\Course::where('slug', $value)->firstOrFail();
|
||||
});
|
||||
|
||||
Route::bind('lesson', function ($value) {
|
||||
return \App\Models\Lesson::where('slug', $value)->firstOrFail();
|
||||
});
|
||||
|
||||
Route::bind('chapter', function ($value) {
|
||||
return \App\Models\Chapter::where('slug', $value)->firstOrFail();
|
||||
});
|
||||
|
||||
|
||||
Route::bind('section', function ($value) {
|
||||
return LessonSection::where('slug', $value)->firstOrFail();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Telescope\IncomingEntry;
|
||||
use Laravel\Telescope\Telescope;
|
||||
use Laravel\Telescope\TelescopeApplicationServiceProvider;
|
||||
|
||||
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Telescope::night();
|
||||
|
||||
$this->hideSensitiveRequestDetails();
|
||||
|
||||
$isLocal = $this->app->environment('local');
|
||||
|
||||
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
|
||||
return $isLocal ||
|
||||
$entry->isReportableException() ||
|
||||
$entry->isFailedRequest() ||
|
||||
$entry->isFailedJob() ||
|
||||
$entry->isScheduledTask() ||
|
||||
$entry->hasMonitoredTag();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent sensitive request details from being logged by Telescope.
|
||||
*/
|
||||
protected function hideSensitiveRequestDetails(): void
|
||||
{
|
||||
if ($this->app->environment('local')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Telescope::hideRequestParameters(['_token']);
|
||||
|
||||
Telescope::hideRequestHeaders([
|
||||
'cookie',
|
||||
'x-csrf-token',
|
||||
'x-xsrf-token',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Telescope gate.
|
||||
*
|
||||
* This gate determines who can access Telescope in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewTelescope', function ($user) {
|
||||
return in_array($user->email, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ExternalBusyWindow;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AvailabilityService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $tz = 'Europe/London',
|
||||
private readonly int $bufferMins = 40
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if a single slot (date + time) is blocked by external busy windows,
|
||||
* considering a travel buffer before start and after end.
|
||||
*/
|
||||
public function isSlotBlockedByExternalBusy(string $dateYmd, string $timeHi, int $durationMins = 60): bool
|
||||
{
|
||||
// Build slot window in UTC
|
||||
$slotStartLocal = Carbon::createFromFormat('Y-m-d H:i', "{$dateYmd} {$timeHi}", $this->tz);
|
||||
$slotStartUtc = $slotStartLocal->clone()->utc();
|
||||
$slotEndUtc = $slotStartUtc->clone()->addMinutes($durationMins);
|
||||
|
||||
// Expand comparison bounds by buffer
|
||||
$slotStartMinusBuffer = $slotStartUtc->clone()->subMinutes($this->bufferMins);
|
||||
$slotEndPlusBuffer = $slotEndUtc->clone()->addMinutes($this->bufferMins);
|
||||
|
||||
// Overlap: busyStart < slotEnd+buffer AND busyEnd > slotStart-buffer
|
||||
return ExternalBusyWindow::query()
|
||||
->where('starts_at', '<', $slotEndPlusBuffer)
|
||||
->where('ends_at', '>', $slotStartMinusBuffer)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* For block-booking: given a set of future candidate dates for a given time,
|
||||
* return the subset that are blocked (with reasons).
|
||||
*
|
||||
* @param array<string> $dateListYmd e.g. ['2025-10-27','2025-11-03', ...]
|
||||
* @return array<int, array{date:string, blocked:bool}>
|
||||
*/
|
||||
public function checkSeriesConflicts(array $dateListYmd, string $timeHi, int $durationMins = 60): array
|
||||
{
|
||||
$results = [];
|
||||
foreach ($dateListYmd as $ymd) {
|
||||
$blocked = $this->isSlotBlockedByExternalBusy($ymd, $timeHi, $durationMins);
|
||||
$results[] = [
|
||||
'date' => $ymd,
|
||||
'blocked' => $blocked,
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ExternalBusyWindow;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class BusyWindowService
|
||||
{
|
||||
public static function getBusyRanges()
|
||||
{
|
||||
// Fetch current + upcoming busy windows
|
||||
return ExternalBusyWindow::where('ends_at', '>=', now())
|
||||
->get()
|
||||
->map(fn($w) => [
|
||||
'start' => Carbon::parse($w->starts_at)->subMinutes(40),
|
||||
'end' => Carbon::parse($w->ends_at)->addMinutes(40),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function isInBusyWindow($slotStart, $busyRanges, $slotLengthMinutes = 60)
|
||||
{
|
||||
$slotEnd = (clone $slotStart)->addMinutes($slotLengthMinutes);
|
||||
|
||||
foreach ($busyRanges as $range) {
|
||||
// If the slot overlaps the busy window in any way
|
||||
if ($slotStart < $range['end'] && $slotEnd > $range['start']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Slot;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CalendarService
|
||||
{
|
||||
/**
|
||||
* Build calendar grid for Mon–Fri with your six daily times.
|
||||
*
|
||||
* @param Carbon $start Monday of the week you want to display
|
||||
* @param Carbon $now Current time (use 'Europe/London')
|
||||
* @param bool $applyShortNotice If true, mark ≤ 120 min as closed (student view)
|
||||
* @return array{headers: array<int, array{date:string,label:string}>, rows: array<int, array{time:string,label:string,cells:array<int,array{date:string,time:string,slot:int|null,status:string,class:string,name:?string}>}>, times: string[]}
|
||||
*/
|
||||
public function build(Carbon $start, Carbon $now, bool $applyShortNotice): array
|
||||
{
|
||||
$end = $start->copy()->addDays(6);
|
||||
|
||||
$weekdays = [1,2,3,4,5]; // Mon..Fri
|
||||
$times = ['09:00:00','10:00:00','11:00:00','14:00:00','15:00:00','16:00:00'];
|
||||
|
||||
// Get all slots with bookings in the visible week
|
||||
$slots = Slot::with(['bookings' => function ($q) use ($start, $end) {
|
||||
$q->whereBetween('date', [$start->toDateString(), $end->toDateString()]);
|
||||
}])->get();
|
||||
|
||||
// Map slots by weekday/time for O(1) lookup in the loop
|
||||
$map = [];
|
||||
foreach ($slots as $s) {
|
||||
$map[(int)$s->weekday][$s->time] = $s;
|
||||
}
|
||||
|
||||
// Column headers
|
||||
$headers = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$d = $start->copy()->addDays($i);
|
||||
$headers[] = [
|
||||
'date' => $d->toDateString(),
|
||||
'label' => $d->format('D d M'),
|
||||
];
|
||||
}
|
||||
|
||||
// Rows (each time)
|
||||
$rows = [];
|
||||
foreach ($times as $time) {
|
||||
$cells = [];
|
||||
foreach ($weekdays as $wd) {
|
||||
$date = $start->copy()->addDays($wd - 1)->toDateString();
|
||||
$slot = $map[$wd][$time] ?? null;
|
||||
$booking = $slot ? $slot->bookings->firstWhere('date', $date) : null;
|
||||
|
||||
// Compute short-notice closure only when required (students)
|
||||
$sessionStart = Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$time}", $now->timezone);
|
||||
$minutesUntil = $now->diffInMinutes($sessionStart, false);
|
||||
$tooSoon = $applyShortNotice ? ($minutesUntil <= 120) : false;
|
||||
|
||||
// Decide status + cell class
|
||||
$status = 'closed';
|
||||
$class = 'bg-gray-100';
|
||||
$name = null;
|
||||
|
||||
if ($booking) {
|
||||
if ($booking->status === 'booked') { $status = 'booked'; $class = 'bg-red-200'; }
|
||||
elseif ($booking->status === 'blocked') { $status = 'blocked'; $class = 'bg-yellow-200'; }
|
||||
} elseif (!$slot) {
|
||||
$status = 'not_seeded'; // slot wasn’t seeded for this weekday/time
|
||||
} elseif ($minutesUntil < 0 || $tooSoon) {
|
||||
$status = 'closed';
|
||||
} else {
|
||||
$status = 'free';
|
||||
$class = 'bg-green-100';
|
||||
}
|
||||
|
||||
if ($booking && $booking->student_name) {
|
||||
$name = $booking->student_name;
|
||||
}
|
||||
|
||||
$cells[] = [
|
||||
'date' => $date,
|
||||
'time' => $time,
|
||||
'slot' => $slot?->id,
|
||||
'status' => $status, // free | booked | blocked | closed | not_seeded
|
||||
'class' => $class,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'time' => $time,
|
||||
'label' => substr($time, 0, 5),
|
||||
'cells' => $cells,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'headers' => $headers,
|
||||
'rows' => $rows,
|
||||
'times' => $times,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
use App\Models\Course;
|
||||
|
||||
class CourseCard extends Component
|
||||
{
|
||||
public Course $course;
|
||||
|
||||
public function __construct(Course $course)
|
||||
{
|
||||
$this->course = $course;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('components.course-card');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class VideoPlayer extends Component
|
||||
{
|
||||
public string $url;
|
||||
public ?string $title;
|
||||
|
||||
public function __construct(?string $url = null, ?string $title = null)
|
||||
{
|
||||
// Coerce null → "" then trim so downstream code is safe
|
||||
$this->url = trim((string) $url);
|
||||
$this->title = $title ?: 'Video';
|
||||
}
|
||||
|
||||
public function provider(): ?string
|
||||
{
|
||||
if ($this->url === '') return null;
|
||||
$u = $this->url;
|
||||
|
||||
if (stripos($u, '<iframe') !== false && preg_match('/src=["\']([^"\']+)["\']/', $u, $m)) {
|
||||
$u = $m[1];
|
||||
}
|
||||
if (preg_match('~(youtube\.com|youtu\.be)~i', $u)) return 'youtube';
|
||||
if (preg_match('~vimeo\.com~i', $u)) return 'vimeo';
|
||||
return null;
|
||||
}
|
||||
|
||||
public function videoId(): ?string
|
||||
{
|
||||
if ($this->url === '') return null;
|
||||
$u = $this->url;
|
||||
|
||||
if (stripos($u, '<iframe') !== false && preg_match('/src=["\']([^"\']+)["\']/', $u, $m)) {
|
||||
$u = $m[1];
|
||||
}
|
||||
if (preg_match('~(youtube\.com|youtu\.be)~i', $u) &&
|
||||
preg_match('~(?:v=|/embed/|youtu\.be/)([0-9A-Za-z_-]{11})~', $u, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (preg_match('~vimeo\.com~i', $u)) {
|
||||
if (preg_match('~vimeo\.com/(?:video/)?(\d+)~', $u, $m)) return $m[1];
|
||||
if (preg_match('~player\.vimeo\.com/video/(\d+)~', $u, $m)) return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function embedSrc(): ?string
|
||||
{
|
||||
$provider = $this->provider();
|
||||
$id = $this->videoId();
|
||||
if (!$provider || !$id) return null;
|
||||
|
||||
return match ($provider) {
|
||||
'youtube' => "https://www.youtube.com/embed/{$id}",
|
||||
'vimeo' => "https://player.vimeo.com/video/{$id}?title=0&byline=0&portrait=0",
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.video-player', [
|
||||
'embedSrc' => $this->embedSrc(),
|
||||
'provider' => $this->provider(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user