Initial commit

This commit is contained in:
Richard Jolley 2026-01-14 00:54:08 +00:00
commit c909d539bc
7178 changed files with 756624 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

View File

61
README.md Normal file
View File

@ -0,0 +1,61 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may al so try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

31
app/Console/Kernel.php Normal file
View File

@ -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');
}
}

View File

@ -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,
]);
}
}

View File

@ -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.");
}
}

View File

@ -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'));
}
}

View File

@ -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,
]);
}
}

View File

@ -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 theyre available
$course->load([
'modules.lessonSections.chapters'
]);
return view('admin.courses.show', compact('course'));
}
}

View File

@ -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');
}
}

View File

@ -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 well 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.');
}
}

View File

@ -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.');
}
}

View File

@ -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.');
}
}

View File

@ -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.');
}
}

View File

@ -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.');
}
}

View File

@ -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)
}
}
}

View File

@ -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 well 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!');
}
}

View File

@ -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}.");
}
}

View File

@ -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('/');
}
}

View File

@ -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));
}
}

View File

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

View File

@ -0,0 +1,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');
}
}

View File

@ -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)]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->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');
}
}

View File

@ -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)]);
}
}

View File

@ -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 Spaties roles
$user->assignRole($user->role);
event(new Registered($user));
Auth::login($user);
return redirect()->route('student.dashboard');
}
}

View File

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

View File

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class BookingsListController extends Controller
{
//
}

View File

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

View File

@ -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.

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 devices 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 wont 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);
}
});
})();

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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
}
}

View File

@ -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"');
}
}

View File

@ -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!');
}
}

View File

@ -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('/');
}
}

View File

@ -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 doesnt 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 cant 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 cant 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}%.");
}
}

View File

@ -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,
]);
}
}

View File

@ -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 MonSun, 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',
]);
}
}

View File

@ -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'));
}
}

View File

@ -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,
]);
}
}

View File

@ -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
}
}

View File

@ -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.');
}
}

View File

@ -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!');
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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),
],
];
}
}

View File

@ -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
}
}
}
}

View File

@ -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');
}
}

25
app/Models/Booking.php Normal file
View File

@ -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);
}
}

41
app/Models/Chapter.php Normal file
View File

@ -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';
}
}

47
app/Models/Course.php Normal file
View File

@ -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';
}
}

View File

@ -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');
}
}

View File

@ -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);
}
}

35
app/Models/Invoice.php Normal file
View File

@ -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);
}
}

View File

@ -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');
}
}

38
app/Models/Lesson.php Normal file
View File

@ -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);
}
}

View File

@ -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 studentteachersubject 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');
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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);
}
});
}
}

View File

@ -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);
}
}

10
app/Models/Level.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Level extends Model
{
protected $fillable = ['name'];
}

20
app/Models/Module.php Normal file
View File

@ -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');
}
}

View File

@ -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);
}
}

18
app/Models/Slot.php Normal file
View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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 studentteacher subject pair to actual classes.
*/
public function teachingArrangements(): HasMany
{
return $this->hasMany(TeachingArrangementStudent::class);
}
/**
* Attendance records for this studentteachersubject combination.
*/
public function attendance(): HasMany
{
return $this->hasMany(LessonAttendance::class);
}
}

View File

@ -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);
}
}

20
app/Models/Subject.php Normal file
View File

@ -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);
}
});
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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 were 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');
}
}

View File

@ -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');
}
}

View File

@ -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 studentteachersubject relationship linked to this slot.
*/
public function studentTeacherSubject(): BelongsTo
{
return $this->belongsTo(StudentTeacherSubject::class, 'student_teacher_subject_id');
}
}

94
app/Models/User.php Normal file
View File

@ -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);
}
}

View File

@ -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
{
//
}
}

View File

@ -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'])
);
}
}

View File

@ -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,
]);
}
}

View File

@ -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();
});
}
}

View File

@ -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, [
//
]);
});
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Services;
use App\Models\Slot;
use Carbon\Carbon;
class CalendarService
{
/**
* Build calendar grid for MonFri 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 wasnt 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,
];
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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(),
]);
}
}

18
artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

Some files were not shown because too many files have changed in this diff Show More