This commit is contained in:
Flatlogic Bot 2026-01-30 15:36:51 +00:00
parent 7a517224e6
commit 33ad70235b
24 changed files with 1316 additions and 66 deletions

View File

@ -135,6 +135,12 @@ include 'includes/header.php';
<a href="learners.php" class="btn btn-outline-primary text-start">
<i class="bi bi-person-plus me-2"></i> Manage Learners
</a>
<a href="leaderboard.php" class="btn btn-outline-warning text-dark text-start border-warning fw-bold">
<i class="bi bi-trophy me-2"></i> Academic Leaderboard
</a>
<a href="collaboration.php" class="btn btn-outline-success text-start border-success fw-bold">
<i class="bi bi-people me-2"></i> Collaboration Hub
</a>
<a href="bulk-upload.php" class="btn btn-outline-primary text-start">
<i class="bi bi-upload me-2"></i> Bulk Upload Learners
</a>
@ -154,4 +160,4 @@ include 'includes/header.php';
</div>
</div>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/header.php'; ?>

View File

@ -0,0 +1,42 @@
<?php
namespace Api\Controllers;
use Api\Core\Controller;
use Api\Core\Response;
use Api\Models\Assessment;
class AssessmentController extends Controller {
public function index() {
$user = $this->auth();
$model = new Assessment();
if ($user['role'] === 'Super Admin') {
$data = $model->all();
} else {
$data = $model->getBySchool($user['school_id']);
}
Response::json($data);
}
public function store() {
$user = $this->auth();
$data = json_decode(file_get_contents('php://input'), true);
if ($user['role'] !== 'Admin' && $user['role'] !== 'Teacher' && $user['role'] !== 'Super Admin') {
Response::error('Unauthorized', 403);
}
$db = db();
$stmt = $db->prepare("INSERT INTO assessments (title, subject, type, school_id) VALUES (:title, :subject, :type, :school_id)");
$stmt->execute([
'title' => $data['title'],
'subject' => $data['subject'],
'type' => $data['type'],
'school_id' => $user['school_id']
]);
Response::json(['id' => $db->lastInsertId(), 'message' => 'Assessment created'], 201);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Api\Controllers;
use Api\Core\Response;
use Api\Core\Auth;
class AuthController {
public function login() {
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
if (!$email || !$password) {
Response::error('Email and password required');
}
$db = db();
$stmt = $db->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
$token = Auth::generateToken([
'user_id' => $user['id'],
'role' => $user['role'],
'school_id' => $user['school_id'],
'email' => $user['email']
]);
Response::json([
'token' => $token,
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'role' => $user['role'],
'name' => $user['name'] ?? ''
]
]);
} else {
Response::error('Invalid credentials', 401);
}
}
public function me() {
$token = Auth::getBearerToken();
if (!$token) Response::error('No token provided', 401);
$payload = Auth::verifyToken($token);
if (!$payload) Response::error('Invalid or expired token', 401);
Response::json(['user' => $payload]);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Api\Controllers;
use Api\Core\Response;
class HealthController {
public function index() {
Response::json([
'status' => 'ok',
'timestamp' => date('c'),
'php_version' => PHP_VERSION
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Api\Controllers;
use Api\Core\Controller;
use Api\Core\Response;
use Api\Models\Learner;
class LearnerController extends Controller {
public function index() {
$user = $this->auth();
$learnerModel = new Learner();
if ($user['role'] === 'Super Admin') {
$learners = $learnerModel->all();
} else {
$learners = $learnerModel->getBySchool($user['school_id']);
}
Response::json($learners);
}
public function show($id) {
$user = $this->auth();
$learnerModel = new Learner();
$learner = $learnerModel->find($id);
if (!$learner) Response::error('Learner not found', 404);
if ($user['role'] !== 'Super Admin' && $learner['school_id'] != $user['school_id']) {
Response::error('Unauthorized', 403);
}
Response::json($learner);
}
}

54
api/v1/Core/Auth.php Normal file
View File

@ -0,0 +1,54 @@
<?php
namespace Api\Core;
class Auth {
private static $secret = 'super-secret-key-change-me'; // Should be in an env/config file
public static function generateToken($payload) {
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload['exp'] = time() + (60 * 60 * 24); // 24 hours
$payload = json_encode($payload);
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode($payload);
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, self::$secret, true);
$base64UrlSignature = self::base64UrlEncode($signature);
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
}
public static function verifyToken($token) {
$parts = explode('.', $token);
if (count($parts) !== 3) return false;
list($header, $payload, $signature) = $parts;
$validSignature = hash_hmac('sha256', $header . "." . $payload, self::$secret, true);
if (self::base64UrlEncode($validSignature) !== $signature) return false;
$payloadData = json_decode(self::base64UrlDecode($payload), true);
if (isset($payloadData['exp']) && $payloadData['exp'] < time()) return false;
return $payloadData;
}
public static function getBearerToken() {
$headers = getallheaders();
if (isset($headers['Authorization'])) {
if (preg_match('/Bearer\s(\S+)/', $headers['Authorization'], $matches)) {
return $matches[1];
}
}
return null;
}
private static function base64UrlEncode($data) {
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}
private static function base64UrlDecode($data) {
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Api\Core;
class Controller {
protected function auth() {
$token = Auth::getBearerToken();
if (!$token) Response::error('No token provided', 401);
$payload = Auth::verifyToken($token);
if (!$payload) Response::error('Invalid or expired token', 401);
return $payload;
}
protected function validate($data, $rules) {
$errors = [];
foreach ($rules as $field => $rule) {
if ($rule === 'required' && (!isset($data[$field]) || empty($data[$field]))) {
$errors[] = "Field '{$field}' is required";
}
}
if (!empty($errors)) {
Response::json(['errors' => $errors], 422);
}
}
}

29
api/v1/Core/Model.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace Api\Core;
class Model {
protected $db;
protected $table;
public function __construct() {
$this->db = db();
}
public function all() {
$stmt = $this->db->query("SELECT * FROM {$this->table}");
return $stmt->fetchAll();
}
public function find($id) {
$stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE id = :id");
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
public function where($column, $value) {
$stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE {$column} = :value");
$stmt->execute(['value' => $value]);
return $stmt->fetchAll();
}
}

16
api/v1/Core/Response.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace Api\Core;
class Response {
public static function json($data, $status = 200) {
header('Content-Type: application/json');
http_response_code($status);
echo json_encode($data);
exit;
}
public static function error($message, $status = 400) {
self::json(['error' => $message], $status);
}
}

40
api/v1/Core/Router.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace Api\Core;
class Router {
private $routes = [];
public function add($method, $path, $handler) {
$this->routes[] = [
'method' => $method,
'path' => $path,
'handler' => $handler
];
}
public function handle($method, $uri) {
// Simple router logic: match path exactly or with :id
foreach ($this->routes as $route) {
$pattern = preg_replace('/\/:\w+/', '/(\d+)', $route['path']);
if ($route['method'] === $method && preg_match('#^' . $pattern . '$#', $uri, $matches)) {
array_shift($matches); // remove the full match
return $this->executeHandler($route['handler'], $matches);
}
}
Response::error('Route not found', 404);
}
private function executeHandler($handler, $params) {
list($controllerName, $methodName) = explode('@', $handler);
$controllerClass = "Api\\Controllers\\" . $controllerName;
if (class_exists($controllerClass)) {
$controller = new $controllerClass();
if (method_exists($controller, $methodName)) {
return call_user_func_array([$controller, $methodName], $params);
}
}
Response::error('Controller or method not found', 500);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Api\Models;
use Api\Core\Model;
class Assessment extends Model {
protected $table = 'assessments';
public function getBySchool($school_id) {
$stmt = $this->db->prepare("SELECT * FROM assessments WHERE school_id = :school_id");
$stmt->execute(['school_id' => $school_id]);
return $stmt->fetchAll();
}
public function saveMarks($assessment_id, $marks) {
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare("INSERT INTO marks (assessment_id, learner_id, score) VALUES (:assessment_id, :learner_id, :score) ON DUPLICATE KEY UPDATE score = VALUES(score)");
foreach ($marks as $mark) {
$stmt->execute([
'assessment_id' => $assessment_id,
'learner_id' => $mark['learner_id'],
'score' => $mark['score']
]);
}
$this->db->commit();
return true;
} catch (\Exception $e) {
$this->db->rollBack();
return false;
}
}
}

15
api/v1/Models/Learner.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace Api\Models;
use Api\Core\Model;
class Learner extends Model {
protected $table = 'learners';
public function getBySchool($school_id) {
$stmt = $this->db->prepare("SELECT * FROM learners WHERE school_id = :school_id");
$stmt->execute(['school_id' => $school_id]);
return $stmt->fetchAll();
}
}

41
api/v1/index.php Normal file
View File

@ -0,0 +1,41 @@
<?php
// Basic autoloader
spl_autoload_register(function ($class) {
$prefix = 'Api\\';
$base_dir = __DIR__ . '/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) return;
$relative_class = substr($class, $len);
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) require $file;
});
require_once __DIR__ . '/../../db/config.php';
require_once __DIR__ . '/Core/Response.php';
use Api\Core\Router;
use Api\Core\Response;
$router = new Router();
// Define routes
$router->add('GET', '/health', 'HealthController@index');
$router->add('POST', '/auth/login', 'AuthController@login');
$router->add('GET', '/auth/me', 'AuthController@me');
$router->add('GET', '/learners', 'LearnerController@index');
$router->add('GET', '/learners/:id', 'LearnerController@show');
$router->add('GET', '/assessments', 'AssessmentController@index');
$router->add('POST', '/assessments', 'AssessmentController@store');
// Get request method and URI
$method = $_SERVER['REQUEST_METHOD'];
$uri = $_GET['request'] ?? '/';
// Remove trailing slash
$uri = rtrim($uri, '/');
if (empty($uri)) $uri = '/';
$router->handle($method, $uri);

View File

@ -18,11 +18,11 @@ if (isset($_POST['upload_resource'])) {
$description = $_POST['description'];
$grade = $_POST['grade'];
$subject = $_POST['subject'];
$is_public = isset($_POST['is_public']) ? 1 : 0;
// In a real app, we'd handle file uploads. For this demo, we'll just store the metadata.
try {
$stmt = db()->prepare("INSERT INTO resources (title, description, teacher_id, school_id, grade, subject) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([$title, $description, $user_id, $school_id, $grade, $subject]);
$stmt = db()->prepare("INSERT INTO resources (title, description, teacher_id, school_id, grade, subject, is_public) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$title, $description, $user_id, $school_id, $grade, $subject, $is_public]);
$success = "Resource shared successfully!";
} catch (PDOException $e) {
$error = "Failed to share resource: " . $e->getMessage();
@ -33,34 +33,55 @@ if (isset($_POST['upload_resource'])) {
if (isset($_POST['create_post'])) {
$title = $_POST['title'];
$content = $_POST['content'];
$is_public = isset($_POST['is_public']) ? 1 : 0;
try {
$stmt = db()->prepare("INSERT INTO forum_posts (title, content, author_id, school_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$title, $content, $user_id, $school_id]);
$stmt = db()->prepare("INSERT INTO forum_posts (title, content, author_id, school_id, is_public) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$title, $content, $user_id, $school_id, $is_public]);
$success = "Topic posted successfully!";
} catch (PDOException $e) {
$error = "Failed to post topic: " . $e->getMessage();
}
}
// Fetch Resources
$resources = db()->prepare("SELECT r.*, u.email as teacher_email FROM resources r JOIN users u ON r.teacher_id = u.id WHERE r.school_id = ? ORDER BY r.created_at DESC");
$resources->execute([$school_id]);
$resources = $resources->fetchAll();
// Fetch Resources (Own school or Public from other schools)
$resources_query = "
SELECT r.*, u.email as teacher_email, s.name as school_name
FROM resources r
JOIN users u ON r.teacher_id = u.id
JOIN schools s ON r.school_id = s.id
WHERE r.school_id = :school_id OR r.is_public = 1
ORDER BY r.created_at DESC
";
$resources_stmt = db()->prepare($resources_query);
$resources_stmt->execute(['school_id' => $school_id]);
$resources = $resources_stmt->fetchAll();
// Fetch Forum Posts
$posts = db()->prepare("SELECT p.*, u.email as author_email, (SELECT COUNT(*) FROM forum_comments WHERE post_id = p.id) as comment_count FROM forum_posts p JOIN users u ON p.author_id = u.id WHERE p.school_id = ? ORDER BY p.created_at DESC");
$posts->execute([$school_id]);
$posts = $posts->fetchAll();
// Fetch Forum Posts (Own school or Public from other schools)
$posts_query = "
SELECT p.*, u.email as author_email, s.name as school_name,
(SELECT COUNT(*) FROM forum_comments WHERE post_id = p.id) as comment_count
FROM forum_posts p
JOIN users u ON p.author_id = u.id
JOIN schools s ON p.school_id = s.id
WHERE p.school_id = :school_id OR p.is_public = 1
ORDER BY p.created_at DESC
";
$posts_stmt = db()->prepare($posts_query);
$posts_stmt->execute(['school_id' => $school_id]);
$posts = $posts_stmt->fetchAll();
$pageTitle = "Teacher Collaboration Hub";
$pageTitle = "School Collaboration Hub";
include 'includes/header.php';
?>
<div class="container mt-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people-fill me-2"></i>Collaboration Hub</h2>
<div class="btn-group">
<div>
<h2><i class="bi bi-people-fill me-2 text-primary"></i>Collaboration Hub</h2>
<p class="text-muted small mb-0">Share resources and ideas with your school and the wider community.</p>
</div>
<div class="btn-group shadow-sm">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-upload me-2"></i>Share Resource
</button>
@ -71,18 +92,18 @@ include 'includes/header.php';
</div>
<?php if ($success): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<?php echo $success; ?>
<div class="alert alert-success alert-dismissible fade show shadow-sm" role="alert">
<i class="bi bi-check-circle-fill me-2"></i><?php echo $success; ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<ul class="nav nav-tabs mb-4" id="hubTabs" role="tablist">
<ul class="nav nav-tabs mb-4 border-0" id="hubTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" id="resources-tab" data-bs-toggle="tab" data-bs-target="#resources" type="button" role="tab">Resources</button>
<button class="nav-link active fw-bold border-0 bg-transparent px-4" id="resources-tab" data-bs-toggle="tab" data-bs-target="#resources" type="button" role="tab">Resources</button>
</li>
<li class="nav-item">
<button class="nav-link" id="forum-tab" data-bs-toggle="tab" data-bs-target="#forum" type="button" role="tab">Discussion Forum</button>
<button class="nav-link fw-bold border-0 bg-transparent px-4" id="forum-tab" data-bs-toggle="tab" data-bs-target="#forum" type="button" role="tab">Discussion Forum</button>
</li>
</ul>
@ -91,34 +112,42 @@ include 'includes/header.php';
<div class="tab-pane fade show active" id="resources" role="tabpanel">
<div class="row">
<?php if (empty($resources)): ?>
<div class="col-12 text-center py-5">
<i class="bi bi-folder2-open display-1 text-light"></i>
<div class="col-12 text-center py-5 bg-light rounded-4">
<i class="bi bi-folder2-open display-1 text-muted opacity-25"></i>
<p class="mt-3 text-muted">No resources shared yet. Be the first!</p>
</div>
<?php else: ?>
<?php foreach ($resources as $res): ?>
<div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm border-0">
<div class="card h-100 shadow-sm border-0 position-relative">
<?php if ($res['is_public']): ?>
<span class="badge bg-success position-absolute top-0 end-0 m-3 shadow-sm">Public</span>
<?php endif; ?>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span class="badge bg-light text-dark"><?php echo htmlspecialchars($res['subject']); ?></span>
<span class="badge bg-light text-dark">Grade <?php echo htmlspecialchars($res['grade']); ?></span>
<span class="badge bg-light text-dark border"><?php echo htmlspecialchars($res['subject']); ?></span>
<span class="badge bg-light text-dark border">Grade <?php echo htmlspecialchars($res['grade']); ?></span>
</div>
<h5 class="card-title"><?php echo htmlspecialchars($res['title']); ?></h5>
<h5 class="card-title fw-bold"><?php echo htmlspecialchars($res['title']); ?></h5>
<p class="card-text text-muted small"><?php echo htmlspecialchars($res['description']); ?></p>
<div class="text-muted smaller mb-0">
<i class="bi bi-building me-1"></i> <?php echo htmlspecialchars($res['school_name']); ?>
</div>
</div>
<div class="card-footer bg-white border-0 py-3">
<div class="d-flex align-items-center mb-3">
<div class="flex-shrink-0">
<i class="bi bi-person-circle fs-4 text-primary"></i>
<div class="avatar-xs bg-soft-primary text-primary rounded-circle d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background: rgba(13, 110, 253, 0.1);">
<?= strtoupper(substr($res['teacher_email'], 0, 1)) ?>
</div>
</div>
<div class="flex-grow-1 ms-2">
<div class="small fw-bold"><?php echo explode('@', $res['teacher_email'])[0]; ?></div>
<div class="small fw-bold text-dark"><?php echo explode('@', $res['teacher_email'])[0]; ?></div>
<div class="text-muted smaller"><?php echo date('M d, Y', strtotime($res['created_at'])); ?></div>
</div>
</div>
<button class="btn btn-sm btn-outline-primary w-100">
<i class="bi bi-download me-2"></i>Download Resource
<button class="btn btn-sm btn-primary w-100 rounded-pill">
<i class="bi bi-download me-2"></i>Download
</button>
</div>
</div>
@ -130,32 +159,36 @@ include 'includes/header.php';
<!-- Forum Tab -->
<div class="tab-pane fade" id="forum" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="list-group list-group-flush">
<div class="card shadow-sm border-0 rounded-4">
<div class="list-group list-group-flush rounded-4">
<?php if (empty($posts)): ?>
<div class="text-center py-5">
<i class="bi bi-chat-left-dots display-1 text-light"></i>
<i class="bi bi-chat-left-dots display-1 text-muted opacity-25"></i>
<p class="mt-3 text-muted">Start a conversation with your colleagues.</p>
</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<a href="#" class="list-group-item list-group-item-action py-4 border-0 border-bottom">
<div class="d-flex w-100 justify-content-between align-items-start">
<div class="list-group-item list-group-item-action py-4 border-0 border-bottom position-relative">
<?php if ($post['is_public']): ?>
<span class="badge bg-success-soft text-success border border-success rounded-pill position-absolute top-0 end-0 m-3" style="background: rgba(25, 135, 84, 0.1);">Community</span>
<?php endif; ?>
<div class="d-flex w-100 justify-content-between align-items-start pe-5">
<div class="me-3">
<h5 class="mb-1"><?php echo htmlspecialchars($post['title']); ?></h5>
<p class="mb-1 text-muted"><?php echo htmlspecialchars(substr($post['content'], 0, 150)) . '...'; ?></p>
<div class="small mt-2">
<h5 class="mb-1 fw-bold"><?php echo htmlspecialchars($post['title']); ?></h5>
<p class="mb-2 text-muted"><?php echo htmlspecialchars(substr($post['content'], 0, 150)) . '...'; ?></p>
<div class="small d-flex align-items-center gap-2">
<span class="text-primary fw-bold"><?php echo explode('@', $post['author_email'])[0]; ?></span>
<span class="text-muted ms-2"> <?php echo date('M d, Y', strtotime($post['created_at'])); ?></span>
<span class="text-muted"> <?php echo htmlspecialchars($post['school_name']); ?></span>
<span class="text-muted"> <?php echo date('M d, Y', strtotime($post['created_at'])); ?></span>
</div>
</div>
<div class="text-end">
<span class="badge rounded-pill bg-light text-primary px-3 py-2">
<span class="badge rounded-pill bg-light text-primary px-3 py-2 border">
<i class="bi bi-chat-fill me-1"></i> <?php echo $post['comment_count']; ?>
</span>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
@ -167,21 +200,21 @@ include 'includes/header.php';
<!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title">Share Learning Resource</h5>
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">Share Learning Resource</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Resource Title</label>
<input type="text" name="title" class="form-control" required placeholder="e.g. Grade 10 Math Exam Prep">
<input type="text" name="title" class="form-control rounded-3" required placeholder="e.g. Grade 10 Math Exam Prep">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Grade</label>
<select name="grade" class="form-select" required>
<select name="grade" class="form-select rounded-3" required>
<option value="8">Grade 8</option>
<option value="9">Grade 9</option>
<option value="10">Grade 10</option>
@ -191,22 +224,24 @@ include 'includes/header.php';
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Subject</label>
<input type="text" name="subject" class="form-control" required placeholder="e.g. Mathematics">
<input type="text" name="subject" class="form-control rounded-3" required placeholder="e.g. Mathematics">
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="3" placeholder="Briefly describe the resource..."></textarea>
<textarea name="description" class="form-control rounded-3" rows="3" placeholder="Briefly describe the resource..."></textarea>
</div>
<div class="mb-3">
<label class="form-label">File (Placeholder)</label>
<input type="file" class="form-control" disabled>
<small class="text-muted">File upload disabled for prototype.</small>
<div class="form-check form-switch p-3 bg-light rounded-3 border">
<input class="form-check-input ms-0 me-2" type="checkbox" name="is_public" id="isPublicResource" checked>
<label class="form-check-label fw-bold" for="isPublicResource">Make Public (School Collaboration)</label>
<div class="form-text mt-0 ms-0">Allow teachers from other schools to see and download this.</div>
</div>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="upload_resource" class="btn btn-primary">Upload & Share</button>
<button type="button" class="btn btn-light rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="upload_resource" class="btn btn-primary rounded-pill px-4">Upload & Share</button>
</div>
</form>
</div>
@ -216,29 +251,44 @@ include 'includes/header.php';
<!-- Post Modal -->
<div class="modal fade" id="postModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title">Start Discussion Topic</h5>
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">Start Discussion Topic</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Topic Title</label>
<input type="text" name="title" class="form-control" required placeholder="What do you want to discuss?">
<input type="text" name="title" class="form-control rounded-3" required placeholder="What do you want to discuss?">
</div>
<div class="mb-3">
<label class="form-label">Content</label>
<textarea name="content" class="form-control" rows="5" required placeholder="Share your thoughts or ask a question..."></textarea>
<textarea name="content" class="form-control rounded-3" rows="5" required placeholder="Share your thoughts or ask a question..."></textarea>
</div>
<div class="mb-3">
<div class="form-check form-switch p-3 bg-light rounded-3 border">
<input class="form-check-input ms-0 me-2" type="checkbox" name="is_public" id="isPublicPost" checked>
<label class="form-check-label fw-bold" for="isPublicPost">Make Public (School Collaboration)</label>
<div class="form-text mt-0 ms-0">Allow the wider teaching community to see and reply.</div>
</div>
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="create_post" class="btn btn-primary">Post Topic</button>
<button type="button" class="btn btn-light rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="create_post" class="btn btn-primary rounded-pill px-4">Post Topic</button>
</div>
</form>
</div>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<style>
.nav-tabs .nav-link { color: #6c757d; transition: all 0.2s; }
.nav-tabs .nav-link.active { color: #0d6efd; border-bottom: 3px solid #0d6efd !important; }
.nav-tabs .nav-link:hover { color: #0d6efd; }
.smaller { font-size: 0.8rem; }
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); }
</style>
<?php include 'includes/footer.php'; ?>

View File

@ -0,0 +1,55 @@
-- Migration: Add Gamification (Badges) and School Collaboration (Public resources)
CREATE TABLE IF NOT EXISTS badges (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(100) DEFAULT 'bi-award',
threshold_percent DECIMAL(5,2) DEFAULT NULL, -- Optional mark threshold
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS learner_badges (
id INT AUTO_INCREMENT PRIMARY KEY,
learner_id INT NOT NULL,
badge_id INT NOT NULL,
awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (learner_id) REFERENCES learners(id) ON DELETE CASCADE,
FOREIGN KEY (badge_id) REFERENCES badges(id) ON DELETE CASCADE
);
-- Add is_public to resources and forum_posts for cross-school collaboration
SET @dbname = DATABASE();
SET @tablename = 'resources';
SET @columnname = 'is_public';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname
AND TABLE_NAME = @tablename
AND COLUMN_NAME = @columnname) > 0,
'SELECT 1',
'ALTER TABLE resources ADD COLUMN is_public BOOLEAN DEFAULT 0'
));
PREPARE stmt FROM @preparedStatement;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @tablename = 'forum_posts';
SET @columnname = 'is_public';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname
AND TABLE_NAME = @tablename
AND COLUMN_NAME = @columnname) > 0,
'SELECT 1',
'ALTER TABLE forum_posts ADD COLUMN is_public BOOLEAN DEFAULT 0'
));
PREPARE stmt FROM @preparedStatement;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Seed some initial badges
INSERT IGNORE INTO badges (name, description, icon, threshold_percent) VALUES
('Academic Star', 'Achieved an average of over 80% in assessments.', 'bi-star-fill', 80.00),
('Consistent Performer', 'Achieved an average of over 60% in assessments.', 'bi-check-circle-fill', 60.00),
('Math Whiz', 'Top performer in Mathematics assessments.', 'bi-calculator', NULL),
('Science Explorer', 'Excellence in Science projects.', 'bi-microscope', NULL);

View File

@ -64,6 +64,9 @@ $is_logged_in = isset($_SESSION['user_id']);
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'analytics.php' ? 'active' : '' ?>" href="analytics.php">Analytics</a>
</li>
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'leaderboard.php' ? 'active' : '' ?>" href="leaderboard.php">Leaderboard</a>
</li>
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'collaboration.php' ? 'active' : '' ?>" href="collaboration.php">Hub</a>
</li>
@ -82,6 +85,9 @@ $is_logged_in = isset($_SESSION['user_id']);
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'analytics.php' ? 'active' : '' ?>" href="analytics.php">Analytics</a>
</li>
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'leaderboard.php' ? 'active' : '' ?>" href="leaderboard.php">Leaderboard</a>
</li>
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'collaboration.php' ? 'active' : '' ?>" href="collaboration.php">Hub</a>
</li>
@ -100,6 +106,9 @@ $is_logged_in = isset($_SESSION['user_id']);
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'parent.php' ? 'active' : '' ?>" href="parent.php">Parent Portal</a>
</li>
<li class="nav-item">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) == 'leaderboard.php' ? 'active' : '' ?>" href="leaderboard.php">Leaderboard</a>
</li>
<?php endif; ?>
<?php if ($is_logged_in): ?>
@ -121,4 +130,4 @@ $is_logged_in = isset($_SESSION['user_id']);
</div>
</div>
</div>
</nav>
</nav>

206
leaderboard.php Normal file
View File

@ -0,0 +1,206 @@
<?php
session_start();
require_once 'db/config.php';
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
$db = db();
$school_id = $_SESSION['school_id'];
// Award badges automatically based on performance (Logic)
// Academic Star: Average >= 80%
// Consistent Performer: Average >= 60%
try {
// This is a simple logic to auto-award badges for demonstration
$stmt = $db->prepare("
SELECT l.id, AVG((m.marks_obtained / a.total_marks) * 100) as avg_score
FROM learners l
JOIN marks m ON l.id = m.learner_id
JOIN assessments a ON m.assessment_id = a.id
WHERE l.school_id = ?
GROUP BY l.id
");
$stmt->execute([$school_id]);
$performances = $stmt->fetchAll();
foreach ($performances as $perf) {
if ($perf['avg_score'] >= 80) {
// Award Academic Star (Badge ID 1)
$db->prepare("INSERT IGNORE INTO learner_badges (learner_id, badge_id) VALUES (?, 1)")->execute([$perf['id']]);
} elseif ($perf['avg_score'] >= 60) {
// Award Consistent Performer (Badge ID 2)
$db->prepare("INSERT IGNORE INTO learner_badges (learner_id, badge_id) VALUES (?, 2)")->execute([$perf['id']]);
}
}
} catch (Exception $e) {
// Silently fail if something goes wrong with auto-awarding
}
// Fetch Leaderboard (Top learners by average score)
$query = "
SELECT
l.id, l.full_name, l.grade, l.student_id,
AVG((m.marks_obtained / a.total_marks) * 100) as average_score,
COUNT(m.id) as assessments_count
FROM learners l
JOIN marks m ON l.id = m.learner_id
JOIN assessments a ON m.assessment_id = a.id
WHERE l.school_id = :school_id
GROUP BY l.id
ORDER BY average_score DESC
LIMIT 20
";
$stmt = $db->prepare($query);
$stmt->execute(['school_id' => $school_id]);
$leaderboard = $stmt->fetchAll();
// Fetch Badges for these learners
$badges_query = "
SELECT lb.learner_id, b.name, b.icon, b.description
FROM learner_badges lb
JOIN badges b ON lb.badge_id = b.id
WHERE lb.learner_id IN (SELECT id FROM learners WHERE school_id = ?)
";
$stmt = $db->prepare($badges_query);
$stmt->execute([$school_id]);
$all_learner_badges = $stmt->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_ASSOC);
$pageTitle = "Learner Leaderboard | SOMS";
include 'includes/header.php';
?>
<div class="container py-5">
<div class="text-center mb-5">
<h1 class="display-5 fw-bold mb-2"><i class="bi bi-trophy text-warning me-2"></i>Academic Leaderboard</h1>
<p class="lead text-muted">Celebrating excellence and consistent growth in our school.</p>
</div>
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="card shadow border-0 overflow-hidden">
<div class="card-header bg-primary text-white py-3">
<div class="row align-items-center">
<div class="col-auto">
<span class="badge bg-white text-primary">Top 20</span>
</div>
<div class="col">
<h5 class="mb-0">Current Rankings</h5>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4" style="width: 80px;">Rank</th>
<th>Learner</th>
<th>Grade</th>
<th>Avg. Score</th>
<th>Achievements</th>
</tr>
</thead>
<tbody>
<?php if (empty($leaderboard)): ?>
<tr>
<td colspan="5" class="text-center py-5 text-muted">
No assessment data available yet to generate rankings.
</td>
</tr>
<?php endif; ?>
<?php foreach ($leaderboard as $index => $row): ?>
<?php
$rank = $index + 1;
$score = round($row['average_score'], 1);
$row_badges = $all_learner_badges[$row['id']] ?? [];
$rankClass = '';
if ($rank === 1) $rankClass = 'rank-1';
elseif ($rank === 2) $rankClass = 'rank-2';
elseif ($rank === 3) $rankClass = 'rank-3';
?>
<tr class="<?= $rank <= 3 ? 'table-light' : '' ?>">
<td class="ps-4">
<?php if ($rank === 1): ?>
<div class="rank-circle bg-warning text-dark"><i class="bi bi-award-fill"></i></div>
<?php elseif ($rank === 2): ?>
<div class="rank-circle bg-secondary text-white">2</div>
<?php elseif ($rank === 3): ?>
<div class="rank-circle bg-bronze text-white">3</div>
<?php else: ?>
<span class="text-muted fw-bold"><?= $rank ?></span>
<?php endif; ?>
</td>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary text-white rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
<?= strtoupper(substr($row['full_name'], 0, 1)) ?>
</div>
<div>
<div class="fw-bold"><?= htmlspecialchars($row['full_name']) ?></div>
<div class="text-muted smaller"><?= htmlspecialchars($row['student_id']) ?></div>
</div>
</div>
</td>
<td><span class="badge bg-light text-dark border">Grade <?= htmlspecialchars($row['grade']) ?></span></td>
<td>
<div class="d-flex align-items-center">
<div class="progress flex-grow-1 me-3" style="height: 6px; min-width: 60px;">
<div class="progress-bar bg-primary" role="progressbar" style="width: <?= $score ?>%"></div>
</div>
<span class="fw-bold"><?= $score ?>%</span>
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-2">
<?php if (empty($row_badges)): ?>
<span class="text-muted smaller">No badges yet</span>
<?php else: ?>
<?php foreach ($row_badges as $badge): ?>
<span class="badge bg-soft-warning text-warning border border-warning" data-bs-toggle="tooltip" title="<?= htmlspecialchars($badge['description']) ?>">
<i class="bi <?= htmlspecialchars($badge['icon']) ?> me-1"></i>
<?= htmlspecialchars($badge['name']) ?>
</span>
<?php endforeach; ?>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<style>
.rank-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
}
.bg-bronze { background-color: #cd7f32; }
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.1); }
.smaller { font-size: 0.75rem; }
.rank-1 { background-color: rgba(255, 193, 7, 0.05); }
</style>
<script>
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
</script>
<?php include 'includes/footer.php'; ?>

232
public/app.js Normal file
View File

@ -0,0 +1,232 @@
const API_URL = '/api/v1/index.php?request=';
const state = {
user: JSON.parse(localStorage.getItem('user')) || null,
token: localStorage.getItem('token') || null,
};
const routes = {
'/': homePage,
'/login': loginPage,
'/learners': learnersPage,
'/assessments': assessmentsPage,
};
async function init() {
window.addEventListener('hashchange', router);
router();
updateNav();
}
function router() {
const hash = window.location.hash || '#/';
const path = hash.substring(1);
const page = routes[path] || routes['/'];
page();
}
function updateNav() {
const navLinks = document.getElementById('nav-links');
if (!state.token) {
navLinks.innerHTML = `
<li class="nav-item"><a class="nav-link" href="#/login">Login</a></li>
`;
return;
}
navLinks.innerHTML = `
<li class="nav-item"><a class="nav-link" href="#/">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#/learners">Learners</a></li>
<li class="nav-item"><a class="nav-link" href="#/assessments">Assessments</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="logout-btn">Logout</a></li>
`;
document.getElementById('logout-btn').addEventListener('click', (e) => {
e.preventDefault();
logout();
});
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
state.token = null;
state.user = null;
window.location.hash = '#/login';
updateNav();
}
async function apiFetch(endpoint, options = {}) {
const url = API_URL + endpoint;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
...(state.token ? { 'Authorization': `Bearer ${state.token}` } : {})
}
};
const response = await fetch(url, { ...defaultOptions, ...options });
const data = await response.json();
if (response.status === 401) {
logout();
throw new Error('Unauthorized');
}
return data;
}
function render(html) {
document.getElementById('content').innerHTML = html;
}
async function homePage() {
if (!state.token) {
window.location.hash = '#/login';
return;
}
render(`
<div class="p-5 mb-4 bg-white rounded-3 shadow-sm">
<h1 class="display-5 fw-bold">Welcome, ${state.user.email}</h1>
<p class="col-md-8 fs-4">This is the new static frontend for SOMS Platform. Everything is served from an API.</p>
<hr class="my-4">
<div class="row g-4">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title">Learners</h5>
<p class="card-text">Manage your student records.</p>
<a href="#/learners" class="btn btn-outline-primary">View All</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title">Assessments</h5>
<p class="card-text">Record marks and track progress.</p>
<a href="#/assessments" class="btn btn-outline-primary">Open Hub</a>
</div>
</div>
</div>
</div>
</div>
`);
}
function loginPage() {
render(`
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
<h2 class="fw-bold text-center mb-4">Sign In</h2>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" id="email" class="form-control" value="admin@sowetohigh.edu.za" required>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<input type="password" id="password" class="form-control" value="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
`);
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
const data = await apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
if (data.token) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
state.token = data.token;
state.user = data.user;
updateNav();
window.location.hash = '#/';
}
} catch (err) {
alert('Login failed: ' + err.message);
}
});
}
async function learnersPage() {
render('<div class="text-center"><div class="spinner-border text-primary"></div></div>');
try {
const learners = await apiFetch('/learners');
let html = `
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Learners</h2>
<button class="btn btn-primary">Add New</button>
</div>
<div class="table-responsive bg-white p-3 rounded shadow-sm">
<table class="table table-hover">
<thead>
<tr>
<th>Full Name</th>
<th>Grade</th>
<th>Student ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
learners.forEach(l => {
html += `
<tr>
<td>${l.full_name}</td>
<td>${l.grade}</td>
<td>${l.student_id}</td>
<td><button class="btn btn-sm btn-outline-secondary">Edit</button></td>
</tr>
`;
});
html += '</tbody></table></div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load learners</div>');
}
}
async function assessmentsPage() {
render('<div class="text-center"><div class="spinner-border text-primary"></div></div>');
try {
const assessments = await apiFetch('/assessments');
let html = `
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Assessments</h2>
<button class="btn btn-primary">Create New</button>
</div>
<div class="row g-3">
`;
assessments.forEach(a => {
html += `
<div class="col-md-4">
<div class="card shadow-sm border-0">
<div class="card-body">
<h5 class="card-title">${a.title}</h5>
<h6 class="card-subtitle mb-2 text-muted">${a.subject} - ${a.type}</h6>
<button class="btn btn-sm btn-primary">Record Marks</button>
</div>
</div>
</div>
`;
});
html += '</div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load assessments</div>');
}
}
init();

View File

@ -0,0 +1,100 @@
:root {
--primary-color: #004D40;
--primary-light: #00695C;
--secondary-color: #FFC107;
--bg-color: #F8F9FA;
--surface-color: #FFFFFF;
--text-main: #212529;
--text-muted: #6C757D;
--border-color: #DEE2E6;
--border-radius: 4px;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
.navbar {
background-color: var(--primary-color) !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
font-weight: 700;
letter-spacing: -0.5px;
}
.card {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
transition: transform 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
border-radius: var(--border-radius);
font-weight: 600;
}
.btn-primary:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
}
.table {
background-color: var(--surface-color);
border-radius: var(--border-radius);
overflow: hidden;
}
.table th {
background-color: #F1F3F5;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
font-weight: 700;
color: var(--text-muted);
border-bottom: 2px solid var(--border-color);
}
.attendance-toggle {
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s;
}
.attendance-toggle.present {
background-color: #E8F5E9;
color: #2E7D32;
border: 1px solid #A5D6A7;
}
.attendance-toggle.absent {
background-color: #FFEBEE;
color: #C62828;
border: 1px solid #EF9A9A;
}
.stats-card {
border-left: 4px solid var(--primary-color);
}
.stats-card h3 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.stats-card p {
color: var(--text-muted);
font-size: 0.875rem;
margin: 0;
}

87
public/assets/js/main.js Normal file
View File

@ -0,0 +1,87 @@
// Service Worker Registration for Offline Capability
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('Service Worker registered');
// Check for updates
reg.onupdatefound = () => {
const installingWorker = reg.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('New content is available; please refresh.');
}
};
};
})
.catch(err => console.log('Service Worker registration failed', err));
});
}
// Online/Offline Status
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
function updateOnlineStatus() {
const status = navigator.onLine ? 'online' : 'offline';
console.log('Status changed to:', status);
// Create/Update UI indicator if it doesn't exist
let indicator = document.getElementById('offline-indicator');
if (!navigator.onLine) {
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'offline-indicator';
indicator.className = 'alert alert-warning fixed-bottom m-0 text-center rounded-0';
indicator.style.zIndex = '9999';
indicator.innerHTML = '<i class="bi bi-wifi-off me-2"></i> You are currently offline. Changes will be synced when you reconnect.';
document.body.appendChild(indicator);
}
} else {
if (indicator) {
indicator.remove();
// Show brief "Back online" message
const onlineToast = document.createElement('div');
onlineToast.className = 'alert alert-success fixed-bottom m-0 text-center rounded-0';
onlineToast.style.zIndex = '9999';
onlineToast.innerHTML = '<i class="bi bi-wifi me-2"></i> Back online! Syncing data...';
document.body.appendChild(onlineToast);
setTimeout(() => onlineToast.remove(), 3000);
}
}
}
// Initial check
document.addEventListener('DOMContentLoaded', () => {
updateOnlineStatus();
// Attendance Table Search (Re-initialize if present)
const learnerSearch = document.getElementById('learnerSearch');
if (learnerSearch) {
learnerSearch.addEventListener('keyup', function() {
let filter = this.value.toUpperCase();
let rows = document.querySelector("#learnersTable tbody").rows;
for (let i = 0; i < rows.length; i++) {
let nameCol = rows[i].cells[0].textContent.toUpperCase();
let idCol = rows[i].cells[2].textContent.toUpperCase();
if (nameCol.indexOf(filter) > -1 || idCol.indexOf(filter) > -1) {
rows[i].style.display = "";
} else {
rows[i].style.display = "none";
}
}
});
}
// Attendance Toggle Feedback
const tableRows = document.querySelectorAll('#learnersTable tbody tr');
tableRows.forEach(row => {
const inputs = row.querySelectorAll('input[type="radio"]');
inputs.forEach(input => {
input.addEventListener('change', () => {
row.classList.add('table-light');
setTimeout(() => row.classList.remove('table-light'), 500);
});
});
});
});

41
public/index.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SOMS Platform | Modern School Management</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css">
<link rel="manifest" href="manifest.json">
</head>
<body class="bg-light">
<div id="app">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="#/">SOMS Platform</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto" id="nav-links">
<!-- Links will be injected here -->
</ul>
</div>
</div>
</nav>
<main class="container">
<div id="content">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="app.js"></script>
</body>
</html>

21
public/manifest.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "SOMS Platform",
"short_name": "SOMS",
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0d6efd",
"description": "Modern School Management for Township Schools",
"icons": [
{
"src": "https://placehold.co/192x192/0d6efd/ffffff?text=SOMS",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "https://placehold.co/512x512/0d6efd/ffffff?text=SOMS",
"sizes": "512x512",
"type": "image/png"
}
]
}

39
public/sw.js Normal file
View File

@ -0,0 +1,39 @@
const CACHE_NAME = 'soms-v1-static';
const API_CACHE_NAME = 'soms-v1-api';
const STATIC_ASSETS = [
'./',
'./index.html',
'./app.js',
'./assets/css/custom.css',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API requests strategy: Network first, then cache
if (url.searchParams.has('request')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clonedResponse = response.clone();
caches.open(API_CACHE_NAME).then((cache) => cache.put(event.request, clonedResponse));
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Static assets strategy: Cache first, then network
event.respondWith(
caches.match(event.request).then((response) => response || fetch(event.request))
);
});

5
sw.js
View File

@ -1,4 +1,4 @@
const CACHE_NAME = 'township-schools-v7';
const CACHE_NAME = 'township-schools-v8';
const STATIC_ASSETS = [
'/',
'/index.php',
@ -13,6 +13,7 @@ const STATIC_ASSETS = [
'/notifications.php',
'/analytics.php',
'/collaboration.php',
'/leaderboard.php',
'/events.php',
'/parent.php',
'/assets/css/custom.css',
@ -68,4 +69,4 @@ self.addEventListener('fetch', (event) => {
});
})
);
});
});