summaryrefslogtreecommitdiff
path: root/public/app
diff options
context:
space:
mode:
Diffstat (limited to 'public/app')
-rw-r--r--public/app/config/.gitkeep0
-rw-r--r--public/app/config/app_constants.php171
-rw-r--r--public/app/config/init_routes.php12
-rw-r--r--public/app/controllers/.gitkeep0
-rw-r--r--public/app/controllers/Admin.php58
-rw-r--r--public/app/controllers/Auth.php382
-rw-r--r--public/app/controllers/admin/.gitkeep0
-rw-r--r--public/app/controllers/admin/Course_admin.php676
-rw-r--r--public/app/controllers/admin/Users_admin.php42
-rw-r--r--public/app/controllers/api/.gitkeep0
-rw-r--r--public/app/controllers/api/Common_api.php281
-rw-r--r--public/app/controllers/api/Doc_api.php34
-rw-r--r--public/app/controllers/cli/CliContoller.php55
-rw-r--r--public/app/controllers/cms/Course.php252
-rw-r--r--public/app/extends/.gitkeep0
-rw-r--r--public/app/extends/Cache_service.php82
-rw-r--r--public/app/extends/Classroom_manager.php106
-rw-r--r--public/app/extends/Classroom_user.php121
-rw-r--r--public/app/extends/Send_mail.php212
-rw-r--r--public/app/models/.gitkeep0
-rw-r--r--public/app/models/Jorge.php172
-rw-r--r--public/app/models/_info.md150
-rw-r--r--public/app/models/admin/History_model.php34
-rw-r--r--public/app/models/admin/Privilege_model.php42
-rw-r--r--public/app/models/admin/User_model.php299
-rw-r--r--public/app/models/cms/Course_model.php314
-rw-r--r--public/app/models/cms/Media_model.php36
-rw-r--r--public/app/models/cms/Page_model.php18
-rw-r--r--public/app/models/market/Market_repository.php235
-rw-r--r--public/app/models/market/Payment_template.txt98
-rw-r--r--public/app/models/market/ProductCategories_model.php199
-rw-r--r--public/app/models/market/Product_model.php49
-rw-r--r--public/app/models/todo.md132
-rw-r--r--public/app/routes/.gitkeep0
-rw-r--r--public/app/routes/api.php81
-rw-r--r--public/app/routes/backend.php221
-rw-r--r--public/app/routes/frontend.php169
-rw-r--r--public/app/routes/user.php84
-rw-r--r--public/app/views/.gitkeep0
-rw-r--r--public/app/views/admin/admin-menu.php47
-rw-r--r--public/app/views/admin/categories.php91
-rw-r--r--public/app/views/admin/edit_lesson.php159
-rw-r--r--public/app/views/admin/edit_page.php139
-rw-r--r--public/app/views/admin/files.php96
-rw-r--r--public/app/views/admin/lessons.php126
-rw-r--r--public/app/views/admin/pages.php109
-rw-r--r--public/app/views/admin/panel.php4
-rw-r--r--public/app/views/admin/privileges.php93
-rw-r--r--public/app/views/admin/skeleton.php45
-rw-r--r--public/app/views/components/breadcrumbs.php30
-rw-r--r--public/app/views/components/courses_menu.php97
-rw-r--r--public/app/views/components/footer.php240
-rw-r--r--public/app/views/components/header_includes.php59
-rw-r--r--public/app/views/components/lessons_list.php54
-rw-r--r--public/app/views/components/subcategories.php19
-rw-r--r--public/app/views/components/top_bar.php40
-rw-r--r--public/app/views/components/user-management.php65
-rw-r--r--public/app/views/error/404.php65
-rw-r--r--public/app/views/error/general.php64
-rw-r--r--public/app/views/group.php13
-rw-r--r--public/app/views/item.php4
-rw-r--r--public/app/views/simple/Model.php887
-rw-r--r--public/app/views/simple/Render.php182
-rw-r--r--public/app/views/simple/category.php156
-rw-r--r--public/app/views/simple/index.php123
-rw-r--r--public/app/views/simple/lorem-dates.php18
-rw-r--r--public/app/views/simple/lorem-intros.php721
-rw-r--r--public/app/views/simple/lorem-posts.php93
-rw-r--r--public/app/views/simple/post.php236
-rw-r--r--public/app/views/templates/course.php93
-rw-r--r--public/app/views/templates/lesson.php82
-rw-r--r--public/app/views/templates/page.php64
-rw-r--r--public/app/views/user/login.php91
-rw-r--r--public/app/views/user/new-password.php65
-rw-r--r--public/app/views/user/registration.php120
-rw-r--r--public/app/views/welcome.php71
76 files changed, 9478 insertions, 0 deletions
diff --git a/public/app/config/.gitkeep b/public/app/config/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/config/.gitkeep
diff --git a/public/app/config/app_constants.php b/public/app/config/app_constants.php
new file mode 100644
index 0000000..b46bc51
--- /dev/null
+++ b/public/app/config/app_constants.php
@@ -0,0 +1,171 @@
+<?php
+// Contents
+// Central Defaults (name)
+// Default Values (user-privileges and user-roles)
+
+
+// read ini file if exist
+// --- -- -- - - -
+if (file_exists("../core/auth/.env")) {
+ $env = parse_ini_file("../core/auth/.env");
+}
+
+
+
+
+// CENTRAL DEFAULTS ////////////////////////////////////////////////////////////
+// -----------------------------------------------------------------------------
+
+// NOTE:
+// if .env file exists ... do not touch anything
+// else ... replace $env[*] values with your setup
+
+// WEB-APP name and url
+// --- -- -- - - -
+define('SITE_TITLE', 'Classroom');
+define('SITE_URL', 'http://localhost');
+
+// email setup
+// --- -- -- - - -
+ // SMTP
+define('MAIL_MAILER', $env['MAIL_MAILER']);
+define('MAIL_HOST', $env['MAIL_HOST']);
+define('MAIL_PORT', $env['MAIL_PORT']);
+define('MAIL_USERNAME', $env['MAIL_USERNAME']);
+define('MAIL_PASSWORD', $env['MAIL_PASSWORD']);
+define('MAIL_ENCRYPTION', $env['MAIL_ENCRYPTION']);
+ // Mailjet
+define('MAILJET_APIKEY', $env['MAILJET_APIKEY']); // apikey
+define('MAILJET_SECRET', $env['MAILJET_SECRET']); // secret
+define('MAILJET_TICKET', $env['MAILJET_TICKET']); // apikey:secret
+ // sender info
+define('MAIL_FROM_NAME', $env['MAIL_FROM_NAME']);
+define('NO_REPLY_EMAIL', $env['NO_REPLY_EMAIL']);
+define('REPLY_TO_EMAIL', $env['REPLY_TO_EMAIL']);
+
+// default mail service; options so far: [ mailjet | phpmailer ]
+define('DEFAULT_MAIL_SERVICE', 'mailjet');
+
+
+// DEFAULT VALUES //////////////////////////////////////////////////////////////
+// -----------------------------------------------------------------------------
+
+// default privileges (for the subscribed user)
+// (array of privilege aliases)
+// --- -- -- - - -
+define('DEFAULT_PRIVILEGES', [
+ 1, // economics, level 1
+ 5 // programming. level 1
+]);
+
+
+// User Roles
+// --- -- -- - - -
+define('READER', 1);
+define('EDITOR', 2);
+define('ADMIN', 3);
+
+
+// ACCESS HISTORY //////////////////////////////////////////////////////////////
+// -----------------------------------------------------------------------------
+// (user-)access types
+
+define('TRACK_LOGIN', 1); // login / logout
+define('TRACK_ACCOUNT', 2); // create user / activate account / modidy profile
+define('TRACK_PAYMENTS', 3); // order / payment / refund etc...
+
+// access type strings
+// ---
+define('USER_ACCESS_TYPE', [
+ 1 => 'Login',
+ 2 => 'Account',
+ 3 => 'Payment'
+]);
+
+
+
+// ERRORS AND ERROR MESSAGES ///////////////////////////////////////////////////
+// -----------------------------------------------------------------------------
+
+define('EMPTY_REQUIRED_FIELDS', 'Δεν έχουν συμπληρωθεί όλα τα υποχρεωτικά πεδία');
+
+
+
+// ASSETS: PATHS USED HEAVILY //////////////////////////////////////////////////
+// -----------------------------------------------------------------------------
+// these paths are used in order to easily find and autoload useful elemets
+
+
+// JAVASCRIPT LIBRARY PATHS
+// -----------------------------------------------------------------------------
+define('JS_DIR', '/content/'); // <base> path to contruct link
+define('JS_LIBRARIES', array( // path after <base> to the actual file
+ 'jquery' => 'lib/jquery/dist/jquery.min.js',
+
+ 'require/files' => 'lib/require/require.files.js',
+ 'require/slim' => 'lib/require/require.slim.js',
+
+ 'plugins' => 'lib/plugins/plugins.min.js',
+ 'plugins/setup' => 'lib/plugins/plugins.setup.js'
+ )
+);
+
+
+// CSS ASSET PATHS
+// -----------------------------------------------------------------------------
+define('CSS_DIR', '/content/css/'); // <base> path to contruct link
+define('CSS_FILES', array( // path after <base> to the actual file
+ 'main' => 'main.min.css',
+ 'filter' => 'filter.css',
+ 'overides' => 'overides.css',
+ )
+);
+
+
+// FONTS
+// -----------------------------------------------------------------------------
+define('FONTS_DIR', '/content/fonts/');
+define('FONT_FILES', array(
+ 'iconfont' => 'iconfont.woff2',
+ 'CFAstyStdBold' => 'CFAstyStd-Bold.woff2',
+ 'CFAstyStdBook' => 'CFAstyStd-Book.woff2',
+ 'CFAstyStdMedium' => 'CFAstyStd-Medium.woff2'
+ )
+);
+
+
+
+
+// Mesagges ////////////////////////////////////////////////////////////////////
+// -----------------------------------------------------------------------------
+
+// 404
+// ---
+define('PAGE_404_TITLE', '404: Not Found');
+
+// registration
+// ---
+define('REGISTRATION_SUCCESS', "<h3>Επιτυχής Εγγραφή</h3>
+ <p>
+ Παρακαλώ ελέγξτε το email σας και ακολουθήστε το σύνδεσμο ενεργοποίησης
+ που σας 'εχει αποσταλεί, ώστε να έχετε πρόσβαση σε επιπλέον περιεχόμενο του site
+ </p>"
+);
+define ('REGISTRATION_USER_EXISTS', "<h3>Αποτυχία Εγγραφής</h3>
+ <p>
+ Υπάρχει ήδη χρήστης με αυτό το email. Αν δεν θυμάστε το password
+ μπορείτε να κάνετε επαναφορά του ακολουθώντας
+ <a href='/reset-password'>αυτό το σύνδεσμο</a>.
+ </p>"
+);
+
+
+// activation
+// ---
+define('ACCOUNT_ACTIVATED_TITLE', 'Ο λογαριασμός σας έχει ενεργοποιηθεί');
+define('ACCOUNT_ACTIVATED_MESSAGE', 'Τώρα μπορείτε να κάνετε
+ <a href="/login">Είσοδο στο σύστημα</a>
+ για να δείτε περισσότερο περιεχόμενο');
+define('NOT_VALID_ACTIVATION_TITLE', 'Σφάλμα!');
+define('NOT_VALID_ACTIVATION_MESSAGE', 'Ο κωδικός ενεργοποίησης δεν είναι σωστός.
+ Μήπως τον έχετε ενεργοποιήσει στο παρελθόν;'); \ No newline at end of file
diff --git a/public/app/config/init_routes.php b/public/app/config/init_routes.php
new file mode 100644
index 0000000..22d6ec0
--- /dev/null
+++ b/public/app/config/init_routes.php
@@ -0,0 +1,12 @@
+<?php
+
+// Add Routes
+// -----------------------------------------------------------------------------
+
+require_once 'app/routes/api.php'; // API: =/api/{table}/{id};/api/*
+
+require_once 'app/routes/backend.php'; // Backend: =/admin/*
+
+require_once 'app/routes/user.php'; // User account management
+
+require_once 'app/routes/frontend.php'; // Frontend =/* (whatever)
diff --git a/public/app/controllers/.gitkeep b/public/app/controllers/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/controllers/.gitkeep
diff --git a/public/app/controllers/Admin.php b/public/app/controllers/Admin.php
new file mode 100644
index 0000000..6f6858c
--- /dev/null
+++ b/public/app/controllers/Admin.php
@@ -0,0 +1,58 @@
+<?php
+namespace app\controllers;
+
+use Registry;
+use Render;
+
+// user classes and models
+use app\extends\Classroom_user;
+use app\extends\Classroom_manager;
+use app\models\admin\User_model;
+
+use app\extends\Send_mail;
+use app\extends\Mail_jet;
+
+
+/** class Auth
+ *
+ * handles user's Authentication and Authorizarion
+ *
+ */
+class Admin {
+
+ /** admin cateogories
+ *
+ */
+ public static function categories()
+ {
+ Render::view('admin/categories');
+ }
+
+
+ /** admin lessons
+ *
+ */
+ public static function lessons()
+ {
+
+ }
+
+
+ /** admin pages
+ *
+ */
+ public static function pages()
+ {
+
+ }
+
+ /** admin users
+ *
+ */
+ public static function users()
+ {
+
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/controllers/Auth.php b/public/app/controllers/Auth.php
new file mode 100644
index 0000000..4a6b9ed
--- /dev/null
+++ b/public/app/controllers/Auth.php
@@ -0,0 +1,382 @@
+<?php
+namespace app\controllers;
+
+use Registry;
+use Render;
+
+// user classes and models
+use app\extends\Classroom_user;
+use app\extends\Classroom_manager;
+use app\models\admin\User_model;
+
+use app\extends\Send_mail;
+use app\extends\Mail_jet;
+
+
+/** class Auth
+ *
+ * handles user's Authentication and Authorizarion
+ *
+ */
+class Auth {
+
+ /** login
+ *
+ * checks visitor's credentials;
+ * if valid, authenticates user
+ *
+ */
+ public static function login()
+ {
+ $req = Registry::get('REQUEST');
+
+ // get the record of the target user
+ $record = User_model::checkUser($req->POST['email']);
+
+ // if no user exists, return false
+ if ($record === false) return false;
+
+ // user is valid; check user password
+ // create a user object
+ $user = (new Classroom_user())
+ ->setID($record['id'])
+ ->setUserName($record['email'])
+ ->setName($record['first_name'] .' '. $record['last_name'])
+ ->setPassword($record['password'])
+ ->setEnabled($record['active']);
+
+ // let user manager to validate user credentials
+ $userManager = new Classroom_manager();
+
+ if ($userManager->isPasswordValid($user, $req->POST['password'])) {
+
+ // get user's security attributes
+ $attributes = User_Model::getUser($record['id']);
+ $roles = json_decode($attributes['Roles_json']);
+ $user
+ ->setRoles($roles)
+ ->setPrivileges(
+ array_merge(
+ json_decode($attributes['RootPrivileges_json']),
+ self::merge_lists_array(
+ json_decode($attributes['SubPrivileges_json'])
+ )
+ )
+ );
+
+ // regeneration session ID (prevent session fixation)
+ session_regenerate_id();
+ // set cookie for connected user
+ setcookie(
+ 'cluser',
+ 'connected;'. $user->getName(),
+ time()+60*60*8, // 8 hours
+ '/'
+ );
+
+ // check if admin (and redirect differently)
+ $is_admin = (!empty(array_intersect([1,2,3], $roles)));
+
+ // login OK, set Token in session
+ $userManager->createUserToken($user);
+ return [
+ 'success' => true,
+ 'goto' => $is_admin ? '/admin/lessons' : '/user/profile',
+ ];
+
+ } else {
+ return false;
+ }
+ }
+
+
+ /**
+ * merges an array of lists to one list
+ */
+ private static function merge_lists_array( $list )
+ {
+ $current = [];
+ foreach($list as $sublist) {
+ $current = array_merge($current, $sublist);
+ }
+ return $current;
+ }
+
+
+ /** activate
+ * resolves a call like: /account/activate?ticket=ca42d68cfba5fbbafeacc010b8e3a551
+ */
+ public static function activate()
+ {
+ $req = Registry::get('REQUEST');
+
+ // get the record of the target user
+ $check = User_model::activate($req->GET['ticket']);
+
+ if ($check == true) {
+ Render::view('/error/general', [
+ 'title' => ACCOUNT_ACTIVATED_TITLE,
+ 'message' => ACCOUNT_ACTIVATED_MESSAGE
+ ]);
+
+ } else {
+ Render::view('/error/general', [
+ 'title' => NOT_VALID_ACTIVATION_TITLE,
+ 'message' => NOT_VALID_ACTIVATION_MESSAGE
+ ]);
+ }
+
+ }
+
+ /** register
+ *
+ * Method for new user registration
+ *
+ */
+ public static function register()
+ {
+ $userManager = new Classroom_manager();
+ $req = Registry::get('REQUEST');
+
+ // create a salted password hash
+ $password = $userManager->cryptPassword($req->POST['password']);
+
+ // echo $password; print_r($req->POST); die(); // OK!
+
+ $user = (new Classroom_user())
+ ->setUserName($req->POST['email'])
+ ->setName($req->POST['name'] .' '. $req->POST['surname'])
+ ->setPassword($password)
+ ->setRoles([ READER ]) // Role: authorized reader
+ ->setPrivileges([]); // none privilege until acount confirmation
+
+ // create user record
+ $activation_code = User_model::registerUser($req->POST, $password);
+
+ // TODO:
+ // handle error on user registration
+ // ...
+ //
+ // if ($activatopn_code[] == -1) {
+ // return [
+ // 'success' => false,
+ // 'message' => REGISTRATION_USER_EXISTS
+ // ];
+ // }
+
+ $send_mail = Send_mail::send_activation_code([
+ 'email' => $req->POST['email'],
+ 'name' => $req->POST['name'] .' '. $req->POST['surname'],
+ 'code' => $activation_code['activation']
+ ]);
+
+ // Send replies
+ if ($send_mail) {
+ return [
+ 'success' => true,
+ 'message' => REGISTRATION_SUCCESS
+ ];
+
+ } else {
+ return [
+ 'success' => false,
+ 'message' => 'error on sending email'
+ ];
+ }
+
+ }
+
+ /** is_connected
+ * checks if the user is connected
+ *
+ * @return true|false
+ */
+ public static function is_connected()
+ {
+ $manager = new Classroom_manager();
+ if ($manager->hasUserToken()) {
+
+ // user is connected;
+ $token = $manager->getUserToken();
+ $user = $token->getUser();
+
+ return $user;
+
+ } else {
+ // user is not connected;
+ return false;
+ }
+ }
+
+
+ /** logout
+ *
+ * performs a secure logout;
+ * regenerates session; deletes cookies;
+ */
+ public static function logout()
+ {
+ $userManager = new Classroom_manager();
+ $userManager->logout();
+
+ // regeneration session ID (prevent session fixation)
+ session_regenerate_id();
+
+ // remove user-conected cookie
+ if (isset($_COOKIE['cluser'])) {
+ unset($_COOKIE['cluser']);
+ setcookie('cluser', '', -1, '/');
+ return true;
+
+ } else {
+ return false;
+ }
+ }
+
+
+
+ /** hasPermition( PERMIT )
+ *
+ * checks if the user owns the specified permition
+ * to access the source
+ *
+ */
+ public static function hasPermition($permit = [0])
+ {
+ if (in_array(0, $permit)) { // permision 0 means public
+ return true; // permision 0 is always granted
+ }
+
+ if ($user = self::is_connected() === false) { // if not connected
+ return false; // then no other permition is granted
+ }
+
+ if ($user instanceof UserInterface) {
+ return ( !empty( array_intersect($permit, $user->getPrivileges()) ) );
+ }
+ }
+
+
+ /** isAuthenticated()
+ *
+ * chechs if the user's roles and permitions
+ * satisfy the specified requirements
+ * to access the source
+ *
+ * @param $requirements (array of rules-array)
+ *
+ * example:
+ * [
+ * [
+ * role => [2, 3]
+ * permition => ['10', '12', '18']
+ * ],
+ * [
+ * role => [1 , 4]
+ * ],
+ * [
+ * permition => [ 3 ]
+ * ]
+ * ]
+ *
+ * defines (and parses to) a requirements rule of:
+ * [
+ * user should be creator or editor
+ * _AND_ have permition 10 or 12 or 18
+ * ]
+ * OR
+ * [
+ * user should be an administrator or developer
+ * ]
+ * OR
+ * [
+ * user should have permition #3
+ * ]
+ *
+ *
+ */
+ public static function isAuthorized($requirements)
+ {
+ $authorized = false;
+ foreach($requirements as $required) {
+
+ if (isset($required['role'])) { // if a role is required
+ if ( (self::isGranted($required['role'])) // authorize both role
+ && (self::hasPermition($required['permition'] ?? [ 0 ])) ) { // and permition
+ // $authorized = true;
+ return true;
+ }
+
+ } else { // else, if not is not required
+ if (self::hasPermition($required['permition'] ?? [ 0 ])) { // authorize permition
+ // $authorized = true;
+ return true;
+ }
+ }
+ }
+ return $authorized;
+ }
+
+
+ public static function forgot_pass()
+ {
+ }
+
+
+
+ public static function validate_otp()
+ {
+ }
+
+
+ /** allowRoles
+ *
+ * method filters access for certain roles
+ * if user is not grented acces, a forbiden message is sent and app ends._
+ * otherwise the method returns true (app will continue)
+ *
+ * @param $allowed (array) : array of allowed roles
+ * @return true or die();
+ */
+ public static function allowRoles($allowed)
+ {
+ $manager = new Classroom_manager();
+ if ($manager->isGranted($allowed)) { // if valid, return true (continue)
+ return true;
+
+ } else { // no user, no access; die._
+ Render::view('error/general', [
+ 'title' => 'Forbidden',
+ 'message' => 'Access is forbidden'
+ ]);
+ die();
+ return false; // this line will never run
+ }
+ }
+
+
+ /** hasValidRole
+ * like allowRoles() but dowes not stop execution
+ *
+ * @return true|false
+ */
+ public static function hasValidRole($allowed)
+ {
+ $manager = new Classroom_manager();
+ return ($manager->isGranted($allowed));
+ }
+
+
+ public static function in_admin_group()
+ {
+ $manager = new Classroom_manager();
+ return ($manager->isGranted([1, 2, 3]));
+ }
+
+
+
+}
+
+
+// NOTE:
+// check: https://netcorecloud.com/tutorials/send-an-email-via-gmail-smtp-server-using-php/ \ No newline at end of file
diff --git a/public/app/controllers/admin/.gitkeep b/public/app/controllers/admin/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/controllers/admin/.gitkeep
diff --git a/public/app/controllers/admin/Course_admin.php b/public/app/controllers/admin/Course_admin.php
new file mode 100644
index 0000000..fcf38c9
--- /dev/null
+++ b/public/app/controllers/admin/Course_admin.php
@@ -0,0 +1,676 @@
+<?php
+
+namespace app\controllers\admin;
+
+use Registry;
+use Render;
+use app\models\cms\Course_model;
+use app\extends\Cache_service;
+
+class Course_admin {
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## COURSE (category) METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** breadcrumbs
+ * ---
+ * responds to ajax GET: /admin/api/categories
+ */
+ public static function breadcrumbs()
+ {
+ Render::json( Cache_service::courses_struct()['breadcrumbs'] );
+ }
+
+ /** add course
+ * ---
+ * @param void (get data from POST)
+ */
+ public static function add_course()
+ {
+ // insert new category; id = new category id
+ $id = Registry::use('database')->query(
+ "INSERT INTO course (parent_id, label) VALUES (:parent, :label)",
+ [
+ ':parent' => Registry::get('REQUEST')->POST['parent_id'],
+ ':label' => Registry::get('REQUEST')->POST['label']
+ ]
+ )->lastInsertID();
+
+ $cache = self::update_courses_cache(); // update category caches
+ return $cache['breadcrumbs']; // return
+ }
+
+
+ /** update course
+ * ---
+ * @param void (get data from POST)
+ */
+ public static function update_course()
+ {
+ Registry::use('database')->query(
+ "UPDATE course SET parent_id = :parent, label = :label
+ WHERE id = :id",
+ [
+ ':id' => Registry::get('REQUEST')->POST['id'],
+ ':parent' => Registry::get('REQUEST')->POST['parent_id'],
+ ':label' => Registry::get('REQUEST')->POST['label']
+ ]
+ );
+ $cache = self::update_courses_cache();
+ return $cache['breadcrumbs'];
+ }
+
+
+ /** update courses cache
+ * --- -- -- - - -
+ * Forces re-caching of courses tree
+ * Shall run if anything changes to courses
+ *
+ * @param void
+ * @return (array): ['tree' => ..., 'breadcrumbs' => ... ]
+ */
+ public static function update_courses_cache()
+ {
+ return Cache_service::courses_struct(PROXY_IGNORE_CACHE);
+ }
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## LESSON METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** all lessons
+ *
+ */
+ public static function all_lessons()
+ {
+ Render::json(Registry::use('database')->runQuery(
+ "SELECT lesson.*, lesson_privilege.privilege_id FROM lesson
+ LEFT JOIN lesson_privilege ON lesson_privilege.lesson_id = lesson.id",
+ []
+ ));
+ }
+
+
+ /** get_lesson
+ *
+ * @param $id (int) : lesson's id
+ */
+ public static function get_lesson($id)
+ {
+ // get (first) lesson with id = $id; render as json
+ Render::json(Registry::use('database')->query(
+ "SELECT lesson.*,
+ ( SELECT
+ CONCAT('[', GROUP_CONCAT(JSON_OBJECT(
+ 'id', media.id,
+ 'label', media.label,
+ 'type', media.type,
+ 'path', media.path )),
+ ']')
+ FROM media
+ LEFT JOIN lesson_media ON lesson_media.media_id = media.id
+ WHERE lesson_media.lesson_id = :id
+ ) AS medias_json,
+ lesson_privilege.privilege_id
+ FROM lesson
+ LEFT JOIN lesson_privilege ON lesson_privilege.lesson_id = lesson.id
+ WHERE lesson.id = :id",
+ [ ':id' => $id]
+ )->getFirst());
+ }
+
+
+ /** add_lesson
+ * insert a new lesson
+ *
+ * all parametres are passed via $_POST array
+ *
+ * POST @param title
+ * POST @param course_id
+ * POST @param intro
+ * POST @param body
+ * POST @param published
+ */
+ public static function add_lesson()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // insert lesson content into daabase
+ $id = Registry::use('database')->query(
+ "INSERT INTO lesson (title, course_id, intro, `body`, `status`)
+ VALUES (:title, :courseid, :intro, :body, :status)",
+ [
+ ':title' => $post['title'],
+ ':courseid' => $post['course_id'],
+ ':intro' => $post['intro'],
+ ':body' => $post['body'],
+ 'status' => $post['status']
+ ]
+ )->lastInsertID();
+
+ // set lesson privileges to database
+ Registry::use('database')->runQuery(
+ "INSERT INTO lesson_privilege (lesson_id, privilege_id)
+ VALUES (:lesson_id, :privilege_id)",
+ [
+ ':lesson_id' => $id,
+ ':privilege_id' => $post['privilege_id']
+ ]
+ );
+
+ if (isset($post['media'])) {
+ // update media's privileges; NOTE: media table
+ self::update_medias_privileges($post['media'], $post['privilege_id']);
+
+ // set lesson's media files; NOTE: lesson_media table
+ // ERROR: self::create_medias_for_lesson($post['media'], $id, $post['privilege_id']);
+ self::create_medias_for_lesson($post['media'], $id);
+
+ // recache media
+ Cache_service::files_attributes(PROXY_IGNORE_CACHE);
+ }
+
+ return true;
+ }
+
+
+ public static function update_lesson()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // print_r($post); die();
+
+ // update lesson's content
+ Registry::use('database')->query(
+ "UPDATE lesson
+ SET title = :title,
+ course_id = :courseid,
+ intro = :intro, `body` = :body,
+ `status` = :status
+ WHERE id = :id",
+ [
+ ':id' => $post['id'],
+ ':title' => $post['title'],
+ ':courseid' => $post['course_id'],
+ ':intro' => $post['intro'],
+ ':body' => $post['body'],
+ ':status' => $post['status']
+ ]
+ );
+
+ // update lesson's privileges
+ Registry::use('database')->runQuery(
+ "UPDATE lesson_privilege SET privilege_id = :privilege_id
+ WHERE lesson_id = :lesson_id",
+ [
+ ':lesson_id' => $post['id'],
+ ':privilege_id' => $post['privilege_id']
+ ]
+ );
+
+ if (isset($post['media'])) {
+ // update media's privileges
+ self::update_medias_privileges($post['media'], $post['privilege_id']);
+ }
+
+ // remove all old lesson's links to media files
+ Registry::use('database')->runQuery(
+ "DELETE from lesson_media WHERE lesson_id = :lesson",
+ [ ':lesson' => $post['id'] ]
+ );
+
+ if (isset($post['media'])) {
+ // update lesson's media files
+ // ERROR: self::create_medias_for_lesson($post['media'], $post['id'], $post['privilege_id']);
+ self::create_medias_for_lesson($post['media'], $post['id']);
+
+ // recache media
+ Cache_service::files_attributes(PROXY_IGNORE_CACHE);
+ }
+
+ return true;
+ }
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## FILE METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** files
+ * echo all files
+ *
+ * @return (array)
+ */
+ public static function files()
+ {
+ return Cache_service::files_attributes();
+ }
+
+
+ /** upload_file
+ * upload the file to the file system
+ *
+ * the method reads the POST and FILES array
+ * to retrieve all needed parametres
+ *
+ * FILES @param file
+ * POST @param folder : lesson's ID or somthing random
+ * POST @param reference : reference type
+ * POST @param
+ */
+ public static function upload_file()
+ {
+ $request = Registry::get('REQUEST');
+
+ $uploaded = self::upload_to_fs(); // upload file to file-system
+
+ if ($uploaded['success']) {
+
+ $media_id = self::define_media([ // define media in database; get id
+ 'title' => $request->POST['title'],
+ 'type' => $uploaded['type'],
+ 'path' => $uploaded['path']
+ ]);
+
+ Render::json([ // render results as json
+ 'success' => true,
+ 'id' => $media_id,
+ 'title' => $request->POST['title'],
+ 'path' => $uploaded['path'],
+ 'type' => $uploaded['type']
+ ]);
+
+ } else {
+ Render::json(['success' => false ]);
+ }
+ }
+
+
+ /** upload to fs
+ * upload file to File-System
+ *
+ * POST @param folder
+ * FILES @param file
+ */
+ private static function upload_to_fs()
+ {
+ $post = Registry::get('REQUEST')->POST;
+ $files = Registry::get('REQUEST')->FILES;
+
+
+ // Checks before uploading the file
+ ////////////////////////////////////////////////////////////////////////
+
+ // ** 1: file is upladed to temporary folder ---------------------------
+ if (! is_uploaded_file($files['file']['tmp_name'])) {
+ return ['success' => false]; // bye!
+ }
+
+ // ** 2: File belongs to the allowed MIME types ------------------------
+ $allowed_file_types = array(
+ 'application/pdf',
+ 'image/png', 'image/jpeg',
+ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ );
+ // Recomended MIME type checking via mime_content_type():
+ $mime_type = mime_content_type($files['file']['tmp_name']);
+ if (! in_array($mime_type, $allowed_file_types)) { // File type NOT allowed ...
+ return ['success' => false]; // bye!
+ }
+
+ $file_name = $files['file']['name'];
+ $file_type = $files['file']['type']; // do not take it for granted
+ $file_size = $files['file']['size'];
+ $file_tmp = $files['file']['tmp_name'];
+
+ $bare_name = pathinfo($file_name, PATHINFO_FILENAME);
+ $file_ext = pathinfo($file_name, PATHINFO_EXTENSION);
+
+
+ // ** 3: filename or size checks may be added --------------------------
+ if ($file_name == "") {
+ return ['success' => false]; // bye!
+ }
+
+
+ // READY to finaly save/upload the file to CDN /////////////////////////
+
+ $folder = MEDIA_STORAGE_ROOT . $post['folder'];
+ if (!file_exists($folder)) { // create folder if not exists
+ mkdir($folder, 0757, true);
+ }
+
+ // print_r([
+ // 'dir' => $folder,
+ // 'file' => $bare_name,
+ // 'ext' => $file_ext,
+ // 'type' => $file_type
+ // ]); die();
+
+ $relative_filename = $post['folder']
+ .'/'
+ . strtolower(self::clear_file_name($bare_name) .'.'. $file_ext);
+ $store_filename = MEDIA_STORAGE_ROOT . $relative_filename;
+
+ if (move_uploaded_file($files["file"]["tmp_name"], $store_filename)) {
+ return [
+ 'success' => true,
+ 'path' => $relative_filename,
+ 'type' => $mime_type
+ ];
+
+ } else { return ['success' => false]; }
+
+ }
+
+
+ /** clear_file_name
+ * replace greek characters and strip symbols
+ */
+ private static function clear_file_name($str)
+ {
+ $el = mb_split( "ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωάέήίόύώϊϋς ", "");
+ $en = str_split("ABGDEZHUIKLMNJOPRSTYFXCVabgdezhuiklmnjoprstyfxcvaehioyviys-");
+ $strip = str_split("!@#$%^&*()+~`[]{};'/<>?=\"");
+
+ return str_replace($strip, '', str_replace($el, $en, $str));
+ }
+
+
+ /** define_media
+ *
+ * create a record in media table
+ *
+ * @param $data (array): [title => , path => , type => mime-type]
+ * @return id (int): id of created media record
+ */
+ private static function define_media($data)
+ {
+ $request = Registry::get('REQUEST');
+
+ $media_id = Registry::use('database')->query(
+ "INSERT INTO media (label, `type`, `path`)
+ VALUES (:label, :mimetype, :filepath)",
+ [
+ 'label' => $data['title'],
+ 'mimetype' => $data['type'],
+ 'filepath' => $data['path']
+ ]
+ )->lastInsertID();
+ return $media_id;
+ }
+
+
+ /** create media for lesson
+ *
+ * links lesson to each media-file of the media `id`s array
+ *
+ * @param $media (array): a list of media-file `id`s
+ * @param $lesson_id (int)
+ */
+ private static function create_medias_for_lesson($medias, $lesson_id)
+ {
+ foreach($medias as $key => $medi) {
+ self::link_media_to_lesson($medi, $lesson_id); // link to lesson
+ }
+ return true;
+ }
+
+
+ /** create media for page
+ *
+ * links page to each media-file of the media `id`s array
+ *
+ * @param $media (array): a list of media-file `id`s
+ * @param $page_id (int)
+ */
+ private static function create_medias_for_page($medias, $page_id)
+ {
+ foreach($medias as $key => $medi) {
+ self::link_media_to_page($medi, $page_id); // link to page
+ }
+ return true;
+ }
+
+
+ /** link one media-file to a specific post
+ *
+ * NOTE:
+ * the method does not check if media is linked already
+ * so be sure that the pair of (media_id,post_id) not exist
+ *
+ * @param $media_id (int)
+ * @param $lesson_id (int)
+ */
+ private static function link_media_to_lesson( $media_id, $lesson_id )
+ {
+ Registry::use('database')->runQuery(
+ "INSERT INTO lesson_media (lesson_id, media_id)
+ VALUES (:lesson, :media)",
+ [
+ 'lesson' => $lesson_id,
+ 'media' => $media_id,
+ ]
+ );
+ return true;
+ }
+
+ /** link one media-file to a specific page
+ *
+ * NOTE:
+ * the method does not check if media is linked already
+ * so be sure that the pair of (media_id, page_id) not exist
+ *
+ * @param $media_id (int)
+ * @param $page_id (int)
+ */
+ private static function link_media_to_page( $media_id, $page_id )
+ {
+ Registry::use('database')->runQuery(
+ "INSERT INTO page_media (page_id, media_id)
+ VALUES (:page, :media)",
+ [
+ 'page' => $page_id,
+ 'media' => $media_id,
+ ]
+ );
+ return true;
+ }
+
+
+ /** update medias privileges
+ *
+ * UPDATE media SET privige WHERE IN {list}
+ *
+ * @param $medias (array) : array of media id(s)
+ * @param $privilege (int) : privilege id
+ * @return true (always)
+ */
+ private static function update_medias_privileges($medias, $privilege)
+ {
+ // create WHERE IN (LIST) holders and params for prepare statement
+ $params = [ ':privilege' => $privilege];
+ $holders = [];
+ foreach($medias as $key => $media) {
+ $params[':id'.$key] = $media;
+ $holders[] = ':id'.$key;
+ }
+ $list = '('. implode(',', $holders) .')';
+
+ // print_r(['params' => $params, 'list' => $list]); die();
+
+ Registry::use('database')->runQuery(
+ "UPDATE media SET privilege_id = :privilege
+ WHERE id IN {$list}",
+ $params
+ );
+
+ return true;
+ }
+
+
+
+
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## PAGE METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** all pages
+ *
+ */
+ public static function all_pages()
+ {
+ Render::json(Registry::use('database')->runQuery(
+ "SELECT * FROM page", []
+ ));
+ }
+
+
+ /** get_page
+ *
+ * @param $id (int) : page's id
+ */
+ public static function get_page($id)
+ {
+ // get (first) page with id = $id; render as json
+ Render::json(Registry::use('database')->query(
+ "SELECT page.*,
+ ( SELECT
+ CONCAT('[', GROUP_CONCAT(JSON_OBJECT(
+ 'id', media.id,
+ 'label', media.label,
+ 'type', media.type,
+ 'path', media.path )),
+ ']')
+ FROM media
+ LEFT JOIN page_media ON page_media.media_id = media.id
+ WHERE page_media.page_id = :id
+ ) AS medias_json
+ FROM page
+ WHERE page.id = :id",
+ [ ':id' => $id]
+ )->getFirst());
+ }
+
+
+ /** add_page
+ * insert a new page
+ *
+ * all parametres are passed via $_POST array
+ *
+ * POST @param title
+ * POST @param body
+ * POST @param status (int): 0 = draft , 1 = published
+ */
+ public static function add_page()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // insert page content into daabase
+ $id = Registry::use('database')->query(
+ "INSERT INTO page (title, `body`, `status`)
+ VALUES (:title, :body, :status)",
+ [
+ ':title' => $post['title'],
+ ':body' => $post['body'],
+ 'status' => $post['status']
+ ]
+ )->lastInsertID();
+
+
+ if (isset($post['media'])) {
+ // update media's privileges;
+ // NOTE: media table; pages have public files (privilege_id=0)
+ self::update_medias_privileges($post['media'], 0);
+
+ // set page's media files
+ // NOTE: page_media table
+ self::create_medias_for_page($post['media'], $id);
+ }
+
+ self::update_pages_cache(); // update pages cache
+
+ return true;
+ }
+
+
+ /** update_page
+ *
+ * POST @param id (int)
+ * POST @param title
+ * POST @param body
+ * POST @param status (int): 0 = draft , 1 = published
+ */
+ public static function update_page()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // print_r($post); die();
+
+ // update page's content
+ Registry::use('database')->query(
+ "UPDATE page
+ SET title = :title, `body` = :body, `status` = :status
+ WHERE id = :id",
+ [
+ ':id' => $post['id'],
+ ':title' => $post['title'],
+ ':body' => $post['body'],
+ ':status' => $post['status']
+ ]
+ );
+
+ if (isset($post['media'])) {
+ // update media's privileges
+ self::update_medias_privileges($post['media'], 0);
+ }
+
+ // remove all old page's links to media files
+ Registry::use('database')->runQuery(
+ "DELETE from page_media WHERE page_id = :page",
+ [ ':page' => $post['id'] ]
+ );
+
+ if (isset($post['media'])) {
+ // update page's media files
+ self::create_medias_for_page($post['media'], $post['id']);
+ }
+
+ self::update_pages_cache(); // update pages cache
+
+ return true;
+ }
+
+
+ /** update pages cache
+ * --- -- -- - - -
+ * Forces re-caching of pages tree
+ * Shall run if anything changes to courses
+ *
+ * @param void
+ * @return (array): ['tree' => ..., 'breadcrumbs' => ... ]
+ */
+ public static function update_pages_cache()
+ {
+ return Cache_service::pages_list(PROXY_IGNORE_CACHE);
+ }
+
+} \ No newline at end of file
diff --git a/public/app/controllers/admin/Users_admin.php b/public/app/controllers/admin/Users_admin.php
new file mode 100644
index 0000000..4e335c1
--- /dev/null
+++ b/public/app/controllers/admin/Users_admin.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace app\controllers\admin;
+
+use Registry;
+use Render;
+
+
+class Users_admin {
+
+ /** privileges
+ * ---
+ * get all privileges
+ *
+ * @param void
+ * @return privileges (array)
+ */
+ public static function privileges()
+ {
+ return Registry::use('database')->runQuery(
+ "SELECT * FROM privilege",[]
+ );
+ }
+
+ /** edit privileges
+ * ---
+ */
+ public static function edit_privileges()
+ {
+ // TODO: ...
+ }
+
+
+ /** update course
+ * ---
+ */
+ public static function update_course()
+ {
+ // TODO: ...
+ }
+
+} \ No newline at end of file
diff --git a/public/app/controllers/api/.gitkeep b/public/app/controllers/api/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/controllers/api/.gitkeep
diff --git a/public/app/controllers/api/Common_api.php b/public/app/controllers/api/Common_api.php
new file mode 100644
index 0000000..a8bd75f
--- /dev/null
+++ b/public/app/controllers/api/Common_api.php
@@ -0,0 +1,281 @@
+<?php
+
+namespace app\controllers\api;
+
+use \Registry;
+use app\models\market\Product_model;
+use app\models\market\ProductCategories_model;
+
+/** Common_api
+ * ---
+ * Controller for handling common API calls
+ * about main tables of the project;
+ *
+ * includes aglo for passing special filters and sorting
+ * (check parse_Where_OrderBy() method)
+ */
+class Common_api
+{
+
+ /** APITables
+ * Method returns allowed tables to accept API calls
+ * The return is an array of [label => data-source] pairs
+ * where 'label' is a friendly name of the datasource
+ * (and 'datasource' the actual table/data-source)
+ */
+ public static function APITables()
+ {
+ /** allowed tables for api call
+ * @return: (array) an of api-call arrays
+ * each api-call array has the folloing form:
+ * [ label => [
+ * 'table' => (string) actual table in database,
+ * (optional) 'filter' => (array) of allowed filtering fields,
+ * (optional) 'sort' => (array) of allowed sorting fields,
+ * ]
+ * ]
+ */
+ return [
+ 'page' => [
+ 'table' => 'pages',
+ 'filter' => ['Title']
+ ],
+ 'product' => [
+ 'table' => 'products',
+ 'filter' => [ 'ID', 'Title' ],
+ 'sort' => ['ID']
+ ],
+ 'category' => [
+ 'table' => 'product_categories',
+ 'filter' => ['Title', 'Hierarchy', 'Level']
+ ]
+ ];
+ }
+
+
+ /** select anything on any (allowed table)
+ * + supports filtering and sorting
+ * check self::parse_Where_OrderBy() for options;
+ * sets a limti of 1000 records
+ */
+ public static function table($table)
+ {
+ $sources = self::APITables();
+ if (array_key_exists($table, $sources)) {
+
+ $query = Registry::get('REQUEST')->QUERY;
+
+ // Get parametres? => parse filters
+ if ($query !== false) {
+
+ $parsed = self::parse_Where_OrderBy(
+ $query,
+ ($sources[$table]['filter'] ?? []),
+ ($sources[$table][ 'sort' ] ?? [])
+ );
+ $where = $parsed['filter'] ? (' WHERE ' . $parsed['filter']) : '';
+ $orderBy = $parsed['sort'] ? (' ORDER BY '. $parsed['sort']) : '' ;
+ $bindArguments = $parsed['bind'];
+
+ } else {
+ $where = '';
+ $orderBy = '';
+ $bindArguments = [];
+ }
+
+ $db = Registry::use('database');
+ $result = $db->runQuery('SELECT *
+ FROM '. $sources[$table]['table']
+ . $where
+ . $orderBy
+ .' LIMIT 1000',
+ $bindArguments
+ );
+
+ reply_json([
+ 'success' => true,
+ 'client' => Registry::get('REQUEST')->SIGNATURE,
+ 'result' => $result
+ ]);
+
+ } else {
+ reply_json(['success' => false]);
+ }
+ die();
+ }
+
+
+ /** get record of {table} by ID
+ * (if table has no ID then return false)
+ */
+ public static function record($table, $id)
+ {
+ $sources = self::APITables();
+ if (array_key_exists($table, $sources)) {
+
+ $db = Registry::use('database');
+ $result = $db->runQuery("SELECT *
+ FROM ". $sources[$table]['table'] ."
+ WHERE ID = :id",
+ [':id' => $id]
+ );
+ reply_json([
+ 'success' => true,
+ 'data' => $result[0]
+ ]);
+
+ } else {
+ reply_json(['status' => false]);
+
+ }
+ die();
+ }
+
+
+ /** category_by_url
+ * product categorie by full-friendly-URL
+ * @param $url (string): full friendly url
+ */
+ public static function category_by_url($url)
+ {
+ $category = proxy(
+ [\app\models\market\ProductCategories_model::class, 'categoryFromUrl'],
+ [ $url ], CACHE_CATEGORY_TTL
+ );
+ if ($category !== false) {
+ reply_json([
+ 'success' => true,
+ 'result' => $category
+ ]);
+
+ } else {
+ reply_json(['status' => false]);
+
+ }
+ die();
+ }
+
+ /** parse Where & OrderBy
+ * -------------------------------------------------------------------------
+ * parses SAFELY the query string to Where {CONDITIONS} and ORDER BY clauses
+ * according to the specified rules.
+ *
+ * The rules:
+ * ** filters: ?fieldname=[operator]:value &...
+ * where operators:[ like | startlike | endlike | eq | gt | gteq | lt |t leq ]
+ *
+ * ** Order by: ?_sort=fieldname[:[asc|desc]][,field[,]]
+ *
+ * for example: ?id=gt:4&active=1&_sort:reputation:desc,category
+ * parses to WHERE id > 4 AND active = 4 ORDER BY reputation desc, catetory asc
+ *
+ * -------------------------------------------------------------------------
+ * arguments:
+ * @param $query (string): string to be parsed
+ * @param $filters (array): the allowed fields to apply filters
+ * @param $sortings (array): the allowed fields to sort the result
+ * @return array('filter'=>(string) , 'sort'=>(string) , 'bind'=>(array))
+ */
+ private static function parse_Where_OrderBy($query, $filters=[], $sortings=[])
+ {
+ // break query to [key => value] pairs
+ parse_str($query, $queryArray);
+
+ $filterClause = []; // array to hold filter/WHERE clauses
+ $sortClause = []; // array to hold sort/ORDER-BY caluses
+ $bindings = []; // array to hold variable bindigs
+
+ // parse filers
+ // ---------------------------------------------------------------------
+ foreach($queryArray as $filter => $value) {
+
+ if (in_array($filter, $filters)) {
+
+ $parts = explode(':', $value ); // that is => [operator], value
+ if (count($parts) == 0) {
+ // forget it
+
+ } else if (count($parts) == 1) {
+ $filterClause[] = "{$filter} = :{$filter}";
+ $bindings[$filter] = $parts[0];
+
+ } else {
+
+ switch ($parts[0]) {
+ case 'like':
+ $filterClause[] = "{$filter} LIKE :{$filter}";
+ $bindings[':'.$filter] = '%'.$parts[1].'%';
+ break;
+
+ case 'startlike':
+ $filterClause[] = "{$filter} LIKE :{$filter}";
+ $bindings[':'.$filter] = $parts[1].'%';
+ break;
+
+ case 'endlike':
+ $filterClause[] = "{$filter} LIKE :{$filter}";
+ $bindings[':'.$filter] = '%'.$parts[1];
+ break;
+
+ case 'gt':
+ $filterClause[] = "{$filter} > :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'gteq':
+ $filterClause[] = "{$filter} >= :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'lt':
+ $filterClause[] = "{$filter} < :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'lteq':
+ $filterClause[] = "{$filter} <= :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'eq':
+ default:
+ $filterClause[] = "{$filter} = :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ }
+ }
+ }
+ }
+ $whereSQL = ($filterClause == [])
+ ? false
+ : implode(' AND ', $filterClause);
+
+
+ // parse sort options
+ // ---------------------------------------------------------------------
+ if (isset($queryArray['_sort'])) {
+ $sortTerms = explode(',', $queryArray['_sort']);
+
+ foreach($sortTerms as $term) {
+
+ $parts = explode(':', $term);
+ if ($parts != [] && in_array($parts[0], $sortings) ) {
+ $sortClause[] = (count($parts)==1)
+ ? $parts[0]
+ : $parts[0] .' '. (($parts[1] == 'desc') ? 'desc' : 'asc');
+ }
+ }
+
+ }
+ $sortSQL = ($sortClause == [])
+ ? false
+ : implode(', ', $sortClause);
+
+ return ([
+ 'filter' => $whereSQL,
+ 'sort' => $sortSQL,
+ 'bind' => $bindings
+ ]);
+
+ }
+
+} \ No newline at end of file
diff --git a/public/app/controllers/api/Doc_api.php b/public/app/controllers/api/Doc_api.php
new file mode 100644
index 0000000..e893552
--- /dev/null
+++ b/public/app/controllers/api/Doc_api.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace app\controllers\api;
+
+use app\models\market\ProductCategories_model;
+use app\models\Jorge;
+
+class Doc_api
+{
+
+ /** */
+ public static function tree($store = 904)
+ {
+ $tree = proxy(
+ [ProductCategories_model::class,'tree'],
+ [], CACHE_ROOT_TTL,
+ PROXY_IGNORE_CACHE
+ );
+ reply_json([ 'success' => true, 'result' => $tree ]);
+ die();
+ }
+
+
+ public static function dbDoc()
+ {
+ $j = new Jorge();
+ reply_json([
+ 'success' => true,
+ 'result' => $j->databaseDocumentation()
+ ]);
+ die();
+
+ }
+} \ No newline at end of file
diff --git a/public/app/controllers/cli/CliContoller.php b/public/app/controllers/cli/CliContoller.php
new file mode 100644
index 0000000..4549b50
--- /dev/null
+++ b/public/app/controllers/cli/CliContoller.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace app\controllers\cli;
+
+/** CliContoller
+ *
+ * A typical cli constoller; this one is used to
+ * re-generate cashes of commonly used entities
+ * with expensive queries like (ex:) categories;
+ *
+ * Proxy calls make use of PROXY_IGNORE_CACHE
+ *
+ * CliController calls Model methods who create
+ * cashed data with the exact same arguments as
+ * when called from the web interface; this way
+ * the Model::method(arguments) triplete creates
+ * the same keys as via the web interface.
+ *
+ * NOTE: Only Cli interface is allowed¨
+ * `if (php_sapi_name() != 'cli') return false;`
+ *
+ * The Cli-interfaced script can run manualy or
+ * (most usual scenario) scheduled via a cron job
+ * in order to refresh cashes befor expiration.
+ *
+ * ---------------------------------------------
+ */
+class CliContoller
+{
+
+
+ /** (re-) cacheCategoriesTree
+ * regenerate cache for product_categories tree
+ * @param (void)
+ */
+ public static function cacheCategoriesTree()
+ {
+ // Only Cli interface is allowed
+ if (php_sapi_name() != 'cli') return false;
+
+ # PROXY call:
+ # CMS \ Course_model::courses_struct():
+ # + Refresh Cache
+ # --------------------------------------
+ echo textColor("Creating category_products tree Cache ... ", \NORMAL);
+ $reply = proxy(
+ [\app\models\cms\Course_model::class, 'courses_struct'],
+ [], \CACHE_ROOT_TTL,
+ \PROXY_IGNORE_CACHE
+ );
+ echo ($reply == false) ? textColor("Failed\n", \FAIL) : textColor("Done!\n", \SUCCESS);
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/controllers/cms/Course.php b/public/app/controllers/cms/Course.php
new file mode 100644
index 0000000..47eac5f
--- /dev/null
+++ b/public/app/controllers/cms/Course.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace app\controllers\cms;
+
+use Registry;
+use Render;
+use app\controllers\Auth;
+use app\models\cms\Course_model;
+use app\extends\Cache_service;
+
+class Course {
+
+
+ /** PERMITION
+ * -------------------------------------------------------------------------
+ */
+
+
+ /** is admin or permited
+ *
+ * shortcut method for checking access authorization
+ *
+ * returns true if user belogns to the admin group
+ * OR has the specified permition/privilege
+ *
+ * @param $privilege_id (int)
+ *
+ * NOTE:
+ * unlike the other authorization methods ...
+ * $privilege_id is NOT an array but a single privilege id
+ *
+ * @return true|false
+ */
+ private static function is_admin_or_permited( $privilege_id )
+ {
+ return (
+ Auth::in_admin_group()
+ || Auth::hasPermition([ $privilege_id ])
+ );
+ }
+
+
+
+ /** FILES
+ * -------------------------------------------------------------------------
+ */
+
+ /** serve file by file_path
+ * (request is valid only for admin users)
+ *
+ * @param $file_path (string): relative file path
+ * GET @param type (string) : media-type of file
+ */
+ public static function serve_file($file_path)
+ {
+ $file = self::get_file_attributes($file_path);
+
+ // if user is authorized
+ if (self::is_admin_or_permited($file['privilege_id'])) {
+
+ $media_type = Registry::get('REQUEST')->GET['type']; // get media-type
+ $real_path = MEDIA_STORAGE_ROOT . $file_path; // construct real path
+
+ if (!file_exists($real_path)) {
+ Render::view('error/404');
+
+ } else {
+ Render::file($real_path, $media_type);
+ }
+
+ } else {
+ Render::view('error/404', [
+ 'error_code' => 403,
+ 'moto' => 'Forbidden',
+ 'message' => ''
+ ]);
+ }
+ }
+
+
+ /** get_file_attributes
+ *
+ * returns attributes of a file
+ * (medias are proxied for speed optimization)
+ *
+ * @param $path (string) : file path
+ * @return $file attributes --or-- false
+ */
+ private static function get_file_attributes($path)
+ {
+ $medias = Cache_service::files_attributes();
+
+ foreach($medias as $key => $medi) {
+
+ if ($medi['path'] == $path) {
+
+ return $medi;
+ }
+ }
+
+ return false;
+ }
+
+
+
+ /** COURSES
+ * -------------------------------------------------------------------------
+ */
+
+ /** course
+ * prepare and render the course view
+ *
+ * @param $id : course id
+ */
+ public static function course($id)
+ {
+ $id = intval($id); // course id
+
+ // collect all data needed for course view
+ // --- -- -- - - -
+ $cache = Cache_service::courses_struct();
+ $tree = $cache['tree'];
+ $breadcrumbs = $cache['breadcrumbs'];
+ $lessons = Course_model::lessons_of_course(intval($id));
+
+ // find parent_ids of current category (to open the menu tree)
+ // ---
+ $course_path = [];
+ foreach($breadcrumbs[$id]['parents'] as $key => $parent) {
+ $course_path[] = intval($parent['id']);
+ }
+ $course_path[] = $id;
+
+ // then Render
+ // --- -- -- - - -
+ Render::view('templates/course', [
+ 'id' => $id,
+ 'title' => $breadcrumbs[ $id ]['rec']['label'],
+ 'categories' => $tree,
+ 'breadcrumbs' => $breadcrumbs,
+ 'lessons' => $lessons,
+ 'course_path' => $course_path,
+ // needed by footer
+ 'entity' => Cache_service::pages_list()
+ ]);
+ }
+
+
+
+ /** LESSONS
+ * ------------------------------------------------------------------------
+ */
+
+
+ /** lesson
+ *
+ * check if user is authorized to view the content;
+ * if so, prepare and render the lesson view
+ *
+ * @param $id : lesson id
+ */
+ public static function lesson($id)
+ {
+ $id = intval($id); // lesson id
+
+ $lesson = Course_model::lesson($id); // get lesson
+ $course_id = $lesson['course_id']; // get course id
+ $pri_id = $lesson['privilege_id']; // privilege needed
+
+
+ if (self::is_admin_or_permited($pri_id)) {
+
+ // collect all data needed for course view
+ // --- -- -- - - -
+ $cache = Cache_service::courses_struct();
+ $tree = $cache['tree'];
+ $breadcrumbs = $cache['breadcrumbs'];
+
+ // find parent_ids of current category (to open the menu tree)
+ // ---
+ $course_path = [];
+ foreach($breadcrumbs[$course_id]['parents'] as $key => $parent) {
+ $course_path[] = intval($parent['id']);
+ }
+ $course_path[] = intval($course_id);
+
+ // NOTE: CRITICAL:
+ // do not pass Class::method directly to the eported variables
+ // $entity = Cache_service::pages_list();
+
+ // var_dump($entity); die();
+
+ // then Render
+ // --- -- -- - - -
+ Render::view('templates/lesson', [
+ // main content
+ 'id' => $id,
+ 'course_id' => $course_id,
+ 'title' => $lesson['title'],
+ 'lesson' => $lesson,
+ // needed for side panel and breadcrunbs
+ 'categories' => $tree,
+ 'breadcrumbs' => $breadcrumbs,
+ 'course_path' => $course_path,
+ // needed by footer
+ 'entity' => Cache_service::pages_list()
+ ]);
+
+ } else { // user is not authorized
+ Render::view('error/general', [
+ 'title' => 'Δεν έχετε πρόσβαση',
+ 'message' => 'Θα πρέπει να αγοράσετε το πακέτο πρόσβασης '
+ . $pri_id
+ .' για να δείτε το περιεχόμενο!
+ <br><br>
+ Αγόρασε τώρα το <button class="btn">πακέτο πρόσβασης '. $pri_id .'</button>'
+ ]);
+ }
+
+ }
+
+
+ /** lesson
+ *
+ * check if user is authorized to view the content;
+ * if so, prepare and render the lesson view
+ *
+ * @param $id : lesson id
+ */
+ public static function page($id)
+ {
+ $id = intval($id); // lesson id
+
+ $pages = Cache_service::pages_list();
+ $page = $pages[$id];
+
+ // then Render
+ // --- -- -- - - -
+ Render::view('templates/page', [
+ // main content
+ 'id' => $id,
+ 'page' => $page,
+ // needed by footer
+ 'entity' => $pages
+ ]);
+
+ }
+
+
+
+
+} \ No newline at end of file
diff --git a/public/app/extends/.gitkeep b/public/app/extends/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/extends/.gitkeep
diff --git a/public/app/extends/Cache_service.php b/public/app/extends/Cache_service.php
new file mode 100644
index 0000000..52311f3
--- /dev/null
+++ b/public/app/extends/Cache_service.php
@@ -0,0 +1,82 @@
+<?php
+namespace app\extends;
+
+use app\models\cms\Course_model;
+use app\models\admin\Privilege_model;
+
+/** Cache service
+ * -----------------------------------------------------------------------------
+ *
+ * class that gathers common source-expensive queries
+ * and helps for performance optimization
+ *
+ * when forcing cache creation the following option is used:
+ * $options = PROXY_IGNORE_CACHE
+ *
+ */
+class Cache_service
+{
+
+ public static function courses_struct( $options = 0 )
+ {
+ return proxy( // cache-get courses
+ [\app\models\cms\Course_model::class, 'courses_struct'],
+ [], CACHE_ROOT_TTL,
+ $options
+ );
+ }
+
+ public static function files_attributes( $options = 0 )
+ {
+ return proxy(
+ [\app\models\cms\Course_model::class, 'files'],
+ [], CACHE_ROOT_TTL,
+ $options
+ );
+ }
+
+
+ public static function privileges_hierarchy( $options = 0 )
+ {
+ return proxy(
+ [\app\models\admin\Privilege_model::class, 'privileges'],
+ [], CACHE_ROOT_TTL,
+ $options
+ );
+ }
+
+ public static function pages_list( $options = 0 )
+ {
+ return proxy(
+ [\app\models\cms\Course_model::class, 'pages'],
+ [], CACHE_ROOT_TTL,
+ $options
+ );
+ }
+
+
+ /** entity
+ *
+ * create and retrieve a cached array of some entity
+ *
+ * @param $entiry (string|method): method of the main CMS model
+ * NOTE: CRITICAL: method must exist in the main CMS model
+ */
+ public static function entity( $entity, $options = 0 )
+ {
+ $allowed_methods = [ // to make sure method existence
+ 'page',
+ 'course',
+ 'lesson',
+ ];
+
+ // TODO: needs testing before release
+ // return proxy(
+ // [\app\models\admin\Course_model::class, $entity],
+ // [], CACHE_ROOT_TTL,
+ // $options
+ // );
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/extends/Classroom_manager.php b/public/app/extends/Classroom_manager.php
new file mode 100644
index 0000000..4d38ba3
--- /dev/null
+++ b/public/app/extends/Classroom_manager.php
@@ -0,0 +1,106 @@
+<?php
+namespace app\extends;
+
+use UserManager;
+use Registry;
+
+
+/** ClassUserManager
+ *
+ * extends (anom's) UserManager
+ *
+ * this way it specializes the UserManagement
+ * according to this app's special rules
+ *
+ */
+class Classroom_manager extends UserManager
+{
+
+ private $index_key = 'email'; // identifing user field (username, email, etc )
+
+ protected $user;
+
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+
+ /** register user
+ *
+ * register into database;
+ * send OTP for account confirmation
+ *
+ * @param (void) : user properties passed via REQUEST->POST
+ *
+ */
+ public function register_user()
+ {
+ $req = Registry::get('REQUEST');
+ $register = User_model::registerUser($req->POST);
+
+ if ($register['success']) {
+
+ //create OTP;
+ $otp = $this->create_OPT($register['id']);
+
+ // send for account confirmation
+
+
+ } else {
+ print_r($register);
+ }
+
+ }
+
+
+ public function login_user()
+ {
+
+ }
+
+
+ /** forgot password
+ *
+ * send an otp
+ * (then verify)
+ *
+ */
+ public function forgot_password()
+ {
+
+ }
+
+
+ /** create OTP
+ *
+ * create a random OTP
+ * keep it into User's database Table
+ *
+ * @param $id (int): user id
+ *
+ */
+ public function create_OTP($id)
+ {
+ $otp = rand(100000,999999);
+ User_model::set_OTP($id, $otp);
+
+ return true;
+ }
+
+
+ /** Verify Account
+ *
+ * check if posted OTP matched the one sent to the user
+ *
+ * @param $identity (array): pair of [index_key => identity_value]
+ * example: [ 'email' => 'geo@roptron.gr' ]
+ *
+ */
+ public function verify_otp($identity, $otp)
+ {
+
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/extends/Classroom_user.php b/public/app/extends/Classroom_user.php
new file mode 100644
index 0000000..c27c4bf
--- /dev/null
+++ b/public/app/extends/Classroom_user.php
@@ -0,0 +1,121 @@
+<?php
+namespace app\extends;
+
+use User;
+use Registry;
+
+
+/** Classroom_user
+ *
+ * extends (anom's) User
+ *
+ * this way it specializes the UserManagement
+ * according to this app's special rules
+ *
+ * Add privileges concept
+ * ---
+ * A Class_user has privileges (which are deferent than Roles)
+ * In fact, privileges extend the authorization requirements for users
+ *
+ * Authorized content is marked with some 'minimum Privilege'
+ * So the content is available to the READER(s) that have certain Privileges
+ *
+ */
+class Classroom_user extends User
+{
+
+ /** user id
+ * @var int
+ */
+ private $id;
+
+ /** user privileges
+ * @var array
+ */
+ private $privileges = [];
+
+ /** user name
+ * @var string
+ */
+ private $name;
+
+ /** SETTERS AND GETTERS
+ * for id, name, privileges attributes
+ * -------------------------------------------------------------------------
+ */
+
+ // id
+ // --- -- -- - - -
+
+ /** getID
+ * @return int
+ */
+ public function getID(): int
+ {
+ return $this->id;
+ }
+
+ /** setID()
+ *
+ * @param int : user id
+ *
+ * @return User
+ */
+ public function setID(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+
+ // name
+ // --- -- -- - - -
+
+ /** getName
+ * @return int
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /** setName()
+ *
+ * @param string : user's full name
+ *
+ * @return User
+ */
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+
+ // privileges
+ // --- -- -- - - -
+
+ /** getPrivileges
+ *
+ * @return privileges (array)
+ */
+ public function getPrivileges(): array
+ {
+ return $this->privileges;
+ }
+
+
+ /** setPrivileges()
+ *
+ * @param array $privileges
+ *
+ * @return User
+ */
+ public function setPrivileges(array $privileges): self
+ {
+ $this->privileges = $privileges;
+ return $this;
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/extends/Send_mail.php b/public/app/extends/Send_mail.php
new file mode 100644
index 0000000..52eab53
--- /dev/null
+++ b/public/app/extends/Send_mail.php
@@ -0,0 +1,212 @@
+<?php
+namespace app\extends;
+
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\SMTP;
+use PHPMailer\PHPMailer\Exception;
+
+
+class Send_mail
+{
+ /** send_activation_code
+ *
+ * construct the envelope = [
+ * email => user email
+ * name => user full name
+ * subject => subject
+ * body => email message body
+ * ]
+ *
+ * then send the email;
+ *
+ * @param $activator (array): [
+ * email => user email,
+ * name => user full name,
+ * code => activation code
+ * ]
+ *
+ * @return success_starus (boolean)
+ *
+ * TODO:
+ * check for email templating:
+ * + https://stackoverflow.com/questions/2391171/how-to-get-output-from-local-script-in-php
+ * + https://stackoverflow.com/questions/171318/how-do-i-capture-php-output-into-a-variable
+ */
+ public static function send_activation_code($activator)
+ {
+ $activation_url = SITE_URL ."/account/activate?ticket=". $activator['code'];
+ $envelope = [
+ 'email' => $activator['email'],
+ 'name' => $activator['name'],
+ 'subject' => 'Εγγραφή στο Classroom',
+ 'body' => "Χαίρετε,<br>
+ το παρόν αυτοποιημένο email σάς έχει σταλεί
+ γιατί έχει γίνει αίτημα εγγραφής σας στο Classroom.<br>
+ <br>
+ Όνομα επαφής: <b>". $activator['name'] ."</b><br>
+ Email : <b>". $activator['email'] ."</b><br>
+ <br>
+ Για να ενεργοποιήσετε την πρόσβασή σας στο site
+ θα πρέπει ακολουθήσετε τον παρακάτω σύνδεσμο:
+ <a href=\"". $activation_url ."\">". $activation_url ."</a>.
+ <br>
+ <br>
+ Μετά την ενεργοποίηση θα έχετε πρόσσβαση
+ στο περιεχόμενο του site.<br>
+ Μην απαντήσετε στο email,
+ δεν υπάρχει φυσική επαφή η οποία να λαμβάνει τυχόν replies."
+ ];
+
+ switch (DEFAULT_MAIL_SERVICE) {
+ case 'mailjet':
+ $reply = self::send_mailjet($envelope);
+ break;
+
+ case 'phpMailer':
+ $reply = self::send_phpMailer($envelope);
+ break;
+ }
+
+ return $reply;
+
+ }
+
+
+ /** send phpMailer
+ *
+ * send email using phpMailer class;
+ * optional SMTP server configuration;
+ *
+ * @param $envelope
+ * @return success_starus (boolean)
+ */
+ public static function send_phpMailer($envelope)
+ {
+ $mail = new PHPMailer();
+
+ # // setup smpt
+ $mail->SMTPDebug = 3;
+ $mail->IsSMTP();
+ $mail->Host = MAIL_HOST;
+ # $mail->SMPTAuth = false;
+ $mail->SMTPSecure = MAIL_ENCRYPTION;
+ # // $mail->Protocol = 'mail';
+
+ $mail->Mailer = MAIL_MAILER;
+ $mail->Port = MAIL_PORT;
+ $mail->Username = MAIL_USERNAME;
+ $mail->Password = MAIL_PASSWORD;
+
+ // setup format
+ $mail->CharSet = 'utf-8';
+ $mail->IsHTML(true);
+
+ // setup THE mail
+ // --- -- -- - - -
+ // It's important not to use the submitter's address as the from address
+ // as it's forgery, which will cause your messages to fail SPF checks.
+ // Use an address in your own domain as the from address;
+ // put the submitter's address in a reply-to
+
+ $mail->setFrom(NO_REPLY_EMAIL, MAIL_FROM_NAME);
+ $mail->addAddress($envelope['email'], $envelope['name']);
+ $mail->addReplyTo(REPLY_TO_EMAIL, MAIL_FROM_NAME);
+ $mail->Subject = $envelope['subject'];
+ $mail->Body = $envelope['body'];
+
+ return (!$mail->send()) ? false : true;
+
+ }
+
+
+ /** send_mailjet
+ *
+ * send email using CURL mail-relay of Mailjet
+ *
+ * @param $envelope
+ * @return success_starus (boolean)
+ */
+ public static function send_mailjet($envelope)
+ {
+ $body = [
+ 'Messages' => [
+ [
+ 'From' => [
+ 'Email' => REPLY_TO_EMAIL,
+ 'Name' => MAIL_FROM_NAME
+ ],
+ 'To' => [
+ [
+ 'Email' => $envelope['email'],
+ 'Name' => $envelope['name']
+ ]
+ ],
+ 'Subject' => $envelope['subject'],
+ 'HTMLPart' => $envelope['body']
+ ]
+ ]
+ ];
+
+ $ch = curl_init();
+
+ curl_setopt($ch, CURLOPT_URL, "https://api.mailjet.com/v3.1/send");
+ curl_setopt($ch, CURLOPT_POST, 1);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array(
+ 'Content-Type: application/json')
+ );
+ curl_setopt(
+ $ch,
+ CURLOPT_USERPWD,
+ MAILJET_APIKEY. ':'. MAILJET_SECRET
+ );
+ $server_output = curl_exec($ch);
+ curl_close ($ch);
+
+ $response = json_decode($server_output);
+
+ return ($response->Messages[0]->Status == 'success');
+
+ }
+
+
+
+}
+
+
+
+
+
+/** NOTE:
+ * -----------------------------------------------------------------------------
+ * (brainstorming)
+ *
+
+Optimization per concept alternative technologies
+--- -- -- - - -
+
+## Session
+File Session
+Database Session
+Redis Session
+Memcached Session
+
+---
+
+## Cache
+File Cache
+Database Cache
+Redis Cache
+Memcashed Cache
+
+---
+
+## Log
+File Log
+Database Log
+Log event to Slack
+Log event to Email
+
+ * -----------------------------------------------------------------------------
+ */
diff --git a/public/app/models/.gitkeep b/public/app/models/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/models/.gitkeep
diff --git a/public/app/models/Jorge.php b/public/app/models/Jorge.php
new file mode 100644
index 0000000..5d79007
--- /dev/null
+++ b/public/app/models/Jorge.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace app\models;
+
+use \Registry;
+
+/** Jorge
+ * a database reference and adminitration toolkit
+ * ---
+ * The name Jorge comes from Uberto Eco's book: 'il nome della rosa',
+ * (Jorge is the blind monk who supervises the monastery's library ;)
+ */
+class Jorge
+{
+
+ /** tables
+ * array to keep all database-tables' critical information.
+ * @key create (array) includes all 'CREATE TABLE' statements
+ * @key relate (array) includes relations between tables
+ * @key fields (array) includes all important fields (when no select*)
+ * @key filter (array) includes all fields used to filter results
+ */
+ private $tables = [
+ 'create' => [],
+ 'relate' => [],
+ 'fields' => []
+ ];
+
+ public function __construct()
+ {
+ $db = Registry::use('database');
+
+ // get all tables
+ $tableList = $db->runQuery("SHOW TABLES", []);
+
+ // get all CREATE TABLE statements
+ foreach($tableList as $tableArray) {
+ $table = array_shift($tableArray);
+ $create = $db->runQuery("SHOW CREATE TABLE {$table}", []);
+ $this->tables['create'][$table] = $create[0]['Create Table'];
+ }
+
+ // define relations
+ $this->tables['relate'] = [
+
+ 'lesson' => [
+ 'course' => 'course.id = lesson.course_id',
+ 'lesson_media' => 'lesson_media.lesson_id = lesson.id',
+ 'lesson_privilege' => 'lesson_privilege.lesson_id = lesson.id'
+ ],
+
+ 'page' => [
+ 'page_media' => 'page_media.page_id = page.id'
+ ],
+
+ 'user' => [
+ 'user_privilege' => 'user_privilege.user_id = user.id'
+ ],
+
+ 'privilege' => [
+ 'user_privilege' => 'user_privilege.privilege_id = privilege.id'
+ ],
+
+ ];
+
+ $this->extractTableFields();
+
+ // return true;
+ }
+
+
+ /** Create...
+ * one or more database tables
+ * ---
+ * @param $datasource (string|void) : table name or none
+ * if none then all tables will be created
+ * @return (boolean)
+ *
+ * NOTE:
+ * for safety reasons this method is not executing SQL;
+ * it just echoes the SQL that needs to be executed in
+ * order to crate all database tables.
+ */
+ public function create($datasource = '')
+ {
+ // if no datasource passed then datasource is all tables
+ if ($datasource == '') {
+ $datasource = array_keys(self::$tables['create']);
+
+ } else {
+ // if datasource exist, make it an array
+ if (array_key_exists($datasource, self::$tables['create'])) {
+ $datasource = [ $datasource ];
+
+ } else {
+ // table does not exist
+ return fasle;
+ }
+ }
+
+ $sql = "";
+ foreach( $datasource as $table ) {
+ $sql .= "-- CREATE ". $table .";\n". self::$tables['create'][$table] . "\n\n";
+ }
+
+ return ['sql' => $sql];
+ }
+
+
+ /** Calculate fields of every table
+ * ---
+ * Parse every CREATE SQL statement and extract list of fields;
+ * Algorithm implements linear-parsing (faster than a recursive one)
+ */
+ private function extractTableFields()
+ {
+ // words used by SQL that can not be field names
+ $nonFields = ['PRIMARY', 'KEY', '(', ')', 'CONSTRAINT', 'UNIQUE']; // reduced list (used on CREATE)
+
+ foreach( $this->tables['create'] as $table => $sqlCreate ) {
+
+ $fields = []; // variable to hold the extracted fields
+
+ // get text between first open-parentesis and last close-parentesis
+ // this is waht lies between 'CREATE TABLE table(' and ') [ENGINE whatever]'
+ // (including the patenteses)
+ preg_match_all(
+ "/\((((?>[^()]+)|(?R))*)\)/",
+ str_replace("\n", '', $sqlCreate),
+ $match
+ );
+ // remove 1st+last parentesis
+ $mainDefinitions = substr($match[0][0], 1, -1);
+
+ // replace comma (,) on sub-parenthesis
+ // ex: 'decimal(18, 2)' turns to 'decimal(18: 2)'
+ // or: 'PRIMARY KEY (id1, id2)' turns to 'PRIMARY KEY (id1: id2)'
+ $sentences = preg_replace(
+ '/\\(([0-9a-zA-Z_`]*)[ ,]([0-9a-zA-Z_` ]*)\\)/',
+ '($1:$2)',
+ $mainDefinitions,
+ -1
+ );
+
+ // devide the banth of sentences to field definitions
+ $defines = explode(',', $sentences);
+
+ // now each denine holds a full field definition
+ // like: `ID` int(11) NOT NULL AUTO_INCREMENT
+
+ foreach($defines as $def) {
+ // check the first term of each sentence
+ $terms = explode(' ', trim($def,));
+ $term = array_shift($terms);
+
+ if (!in_array($term, $nonFields)) {
+ $fields[ str_replace('`', '', $term) ] = implode(' ', $terms);
+ }
+ }
+
+ $this->tables['fields'][$table] = $fields;
+ }
+
+ return;
+ }
+
+ public function databaseDocumentation()
+ {
+ return $this->tables['fields'];
+ }
+
+} \ No newline at end of file
diff --git a/public/app/models/_info.md b/public/app/models/_info.md
new file mode 100644
index 0000000..5504523
--- /dev/null
+++ b/public/app/models/_info.md
@@ -0,0 +1,150 @@
+
+# info about the structrure of the models
+
+According to the former impementation (atcom) the project will need to handle 200+ tables.
+The former project includes 209 tables with 5 or more rows (and 325 tables totaly)
+
+
+
+## Database schema
+
+Main tables and recomended constraints:
+
+```
+SET NAMES utf8;
+SET time_zone = '+00:00';
+SET foreign_key_checks = 0;
+SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';
+
+SET NAMES utf8mb4;
+
+DROP TABLE IF EXISTS `course`;
+CREATE TABLE `course` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `parent_id` int(11) NOT NULL COMMENT 'if 0 then this is a root course',
+ `label` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `lesson`;
+CREATE TABLE `lesson` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `course_id` int(11) NOT NULL,
+ `title` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `body` text COLLATE utf8mb4_unicode_ci NOT NULL,
+ `published` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0 = unpublished, 1 = published',
+ PRIMARY KEY (`id`),
+ KEY `course_id` (`course_id`),
+ CONSTRAINT `lesson_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `course` (`id`),
+ CONSTRAINT `lesson_ibfk_2` FOREIGN KEY (`course_id`) REFERENCES `course` (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `lesson_media`;
+CREATE TABLE `lesson_media` (
+ `lesson_id` int(11) NOT NULL,
+ `media_id` int(11) NOT NULL,
+ PRIMARY KEY (`lesson_id`,`media_id`),
+ KEY `media_id` (`media_id`),
+ CONSTRAINT `lesson_media_ibfk_1` FOREIGN KEY (`lesson_id`) REFERENCES `lesson` (`id`),
+ CONSTRAINT `lesson_media_ibfk_2` FOREIGN KEY (`lesson_id`) REFERENCES `lesson` (`id`),
+ CONSTRAINT `lesson_media_ibfk_3` FOREIGN KEY (`lesson_id`) REFERENCES `lesson` (`id`),
+ CONSTRAINT `lesson_media_ibfk_4` FOREIGN KEY (`media_id`) REFERENCES `media` (`id`),
+ CONSTRAINT `lesson_media_ibfk_5` FOREIGN KEY (`lesson_id`) REFERENCES `lesson` (`id`) ON DELETE NO ACTION,
+ CONSTRAINT `lesson_media_ibfk_6` FOREIGN KEY (`media_id`) REFERENCES `media` (`id`) ON DELETE NO ACTION,
+ CONSTRAINT `lesson_media_ibfk_7` FOREIGN KEY (`lesson_id`) REFERENCES `lesson` (`id`) ON DELETE NO ACTION,
+ CONSTRAINT `lesson_media_ibfk_8` FOREIGN KEY (`media_id`) REFERENCES `media` (`id`) ON DELETE NO ACTION
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `lesson_privilege`;
+CREATE TABLE `lesson_privilege` (
+ `lesson_id` int(11) NOT NULL,
+ `privilege_id` int(11) NOT NULL COMMENT 'minimum privilege required to access the lesson',
+ KEY `lesson_id` (`lesson_id`),
+ KEY `privilege_id` (`privilege_id`),
+ CONSTRAINT `lesson_privilege_ibfk_1` FOREIGN KEY (`lesson_id`) REFERENCES `lesson` (`id`),
+ CONSTRAINT `lesson_privilege_ibfk_2` FOREIGN KEY (`privilege_id`) REFERENCES `privilege` (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `media`;
+CREATE TABLE `media` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `label` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `path` varchar(320) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `referable` tinyint(4) NOT NULL DEFAULT '1' COMMENT '0 = hidden, 1 = referable',
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `page`;
+CREATE TABLE `page` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `title` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
+ `body` text COLLATE utf8mb4_unicode_ci NOT NULL,
+ `published` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0 = unpublished; 1 = published',
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `page_media`;
+CREATE TABLE `page_media` (
+ `page_id` int(11) NOT NULL,
+ `media_id` int(11) NOT NULL,
+ KEY `page_id` (`page_id`),
+ KEY `media_id` (`media_id`),
+ CONSTRAINT `page_media_ibfk_1` FOREIGN KEY (`page_id`) REFERENCES `page` (`id`) ON DELETE NO ACTION,
+ CONSTRAINT `page_media_ibfk_2` FOREIGN KEY (`media_id`) REFERENCES `media` (`id`) ON DELETE NO ACTION
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `privilege`;
+CREATE TABLE `privilege` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `alias` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'keep it simple; use latin',
+ `label` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `alias` (`alias`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `privilege_inherit`;
+CREATE TABLE `privilege_inherit` (
+ `higher_id` int(11) NOT NULL COMMENT 'higher priviledges inherit (include) lower ones',
+ `lower_id` int(11) NOT NULL,
+ PRIMARY KEY (`higher_id`,`lower_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `user`;
+CREATE TABLE `user` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `first_name` int(11) NOT NULL,
+ `last_name` int(11) NOT NULL,
+ `email` int(11) NOT NULL,
+ `expiration` int(11) NOT NULL COMMENT 'account expiration date; 0 = never',
+ `password` int(11) NOT NULL,
+ `salt` int(11) NOT NULL,
+ `otp` int(11) NOT NULL COMMENT 'one time password for reset password',
+ `otp_expiration` int(11) NOT NULL,
+ `active` int(11) NOT NULL COMMENT 'account flag; 1=active, 0=inactive',
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+DROP TABLE IF EXISTS `user_privilege`;
+CREATE TABLE `user_privilege` (
+ `user_id` int(11) NOT NULL,
+ `privilege_id` int(11) NOT NULL,
+ PRIMARY KEY (`user_id`,`privilege_id`),
+ KEY `privilege_id` (`privilege_id`),
+ CONSTRAINT `user_privilege_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
+ CONSTRAINT `user_privilege_ibfk_2` FOREIGN KEY (`privilege_id`) REFERENCES `privilege` (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+```
+
+
diff --git a/public/app/models/admin/History_model.php b/public/app/models/admin/History_model.php
new file mode 100644
index 0000000..04ce630
--- /dev/null
+++ b/public/app/models/admin/History_model.php
@@ -0,0 +1,34 @@
+<?php
+namespace app\models\admin;
+
+use \Registry;
+
+
+class History_model
+{
+
+ /** track user access
+ *
+ */
+ public static function trackUserAccess($user_id, $type, $message, $note='_')
+ {
+
+ Registry::use('database')->query(
+ "INSERT INTO history
+ (user_id, `type`, `message`, `note`, `ip`)
+ VALUES
+ (:uid, :type, :msg, :note, :ip)",
+ [
+ ':uid' => $user_id,
+ ':type' => $type,
+ ':msg' => $message,
+ ':note' => $note,
+ ':ip' => Registry::get('REQUEST')->IP
+ ]
+ )->lastInsertID();
+
+ }
+
+
+
+} \ No newline at end of file
diff --git a/public/app/models/admin/Privilege_model.php b/public/app/models/admin/Privilege_model.php
new file mode 100644
index 0000000..c385484
--- /dev/null
+++ b/public/app/models/admin/Privilege_model.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace app\models\admin;
+
+use Registry;
+use Render;
+
+
+class Privilege_model {
+
+ /** privileges
+ * ---
+ * get all privileges
+ *
+ * @param void
+ * @return privileges (array)
+ */
+ public static function privileges()
+ {
+ return Registry::use('database')->runQuery(
+ "SELECT * FROM privilege",[]
+ );
+ }
+
+ /** edit privileges
+ * ---
+ */
+ public static function edit_privileges()
+ {
+ // TODO: ...
+ }
+
+
+ /** update course
+ * ---
+ */
+ public static function update_course()
+ {
+ // TODO: ...
+ }
+
+} \ No newline at end of file
diff --git a/public/app/models/admin/User_model.php b/public/app/models/admin/User_model.php
new file mode 100644
index 0000000..2885dc1
--- /dev/null
+++ b/public/app/models/admin/User_model.php
@@ -0,0 +1,299 @@
+<?php
+
+namespace app\models\admin;
+
+use \Registry;
+use app\models\admin\History_model as History;
+
+class User_model
+{
+
+ /** check user (by index key)
+ *
+ * @param $indexKey (string) : key for user identification
+ * @param $value (string)
+ *
+ * @param $email (string): user's email
+ */
+ public static function checkUser($email)
+ {
+ $user = Registry::use('database')->query(
+ "SELECT * FROM user WHERE email = :email AND active = 1",
+ [ ':email' => $email ]
+ )->getFirst();
+
+ // if no user, return false
+ if ($user === false) return false;
+
+ return $user;
+ }
+
+
+ /** get user (by index key)
+ *
+ * user detailed array
+ * includes all user properties + granted roles + privileges
+ *
+ * @param $id (int) : user id
+ * @param $value (string)
+ */
+ public static function getUser($id)
+ {
+ $user = Registry::use('database')->query(
+ "SELECT user.*,
+ ( -- construct array (json) of roles granted to user
+ SELECT CONCAT(
+ '[',
+ GROUP_CONCAT(role.id),
+ ']'
+ )
+ FROM `role`
+ WHERE role.id IN (
+ SELECT user_role.role_id
+ FROM user_role
+ WHERE user_role.user_id = :id
+ )
+ ) AS Roles_json,
+ ( -- construct array of (root-)privileges granted to user
+ SELECT CONCAT(
+ '[',
+ GROUP_CONCAT(privilege.id),
+ ']'
+ )
+ FROM privilege
+ WHERE privilege.id IN (
+ SELECT user_privilege.privilege_id
+ FROM user_privilege
+ WHERE user_privilege.user_id = :id
+ )
+ ) AS RootPrivileges_json,
+ ( -- construct array of (root-)privileges granted to user
+ SELECT CONCAT(
+ '[',
+ GROUP_CONCAT(privilege.includes),
+ ']'
+ )
+ FROM privilege
+ WHERE privilege.id IN (
+ SELECT user_privilege.privilege_id
+ FROM user_privilege
+ WHERE user_privilege.user_id = :id
+ )
+ ) AS SubPrivileges_json
+ FROM user
+ WHERE id = :id",
+ [ ':id' => $id ]
+ )->getFirst();
+
+
+ // if no user, return false
+ if ($user === false) return false;
+
+
+ // TODO:
+ // * merge root+sub privilede lists
+ // * convert json strings to php arrays
+
+
+ // TODO:
+ // cache user super array
+
+ return $user;
+ }
+
+
+ /** create user
+ *
+ * creates user record;
+ * assigns privileged (usualy defaults);
+ * creates activation_code
+ *
+ * @param $data (array): Request->POST array
+ * @param $password (string): secure hashed password
+ *
+ * @return $activation_code
+ *
+ */
+ public static function registerUser($data, $password, $privileges = DEFAULT_PRIVILEGES)
+ {
+ $required_fields = [
+ 'name',
+ 'surname',
+ 'email',
+ 'password'
+ ];
+
+ // check required fields
+ $isOK = true;
+ foreach($required_fields as $fi) {
+ if (empty($data[$fi])) $isOK = false;
+ }
+ // if empty required fields exists ... return false
+ if (!$isOK) {
+ return [ "success" => false, 'error' => EMPTY_REQUIRED_FIELDS ];
+ }
+
+
+ // TODO:
+ // check if email exists
+ // ...
+
+ // create an activation code
+ $activation_code = md5($data['email'].time().rand(0, 10000));
+
+ // if isOK go on and...
+ // create user record
+ $new_user_id = Registry::use('database')->query(
+ "INSERT INTO user
+ (`first_name`, `last_name`, `email`, `password`, `active`, `activation`)
+ VALUES
+ (:nam, :surname, :email, :pass, :act, :actcode)",
+ [
+ ':nam' => $data['name'],
+ ':surname' => $data['surname'],
+ ':email' => $data['email'],
+ ':pass' => $password,
+ ':act' => 0, // needs email confirmation to be activated ...
+ ':actcode' => $activation_code // ... with the activation code
+ ]
+ )->lastInsertID();
+
+ // set default privileges
+ self::set_user_privileges($new_user_id, $privileges);
+
+ // set reader role
+ self::set_user_role($new_user_id, 5);
+
+ // update history
+ History::trackUserAccess($new_user_id, TRACK_ACCOUNT, 'Create User Account');
+
+ // return success and user id
+ return [
+ "success" => true,
+ 'id' => $new_user_id ,
+ 'activation' => $activation_code
+ ];
+ }
+
+
+ /** set_user_privileges
+ *
+ * @param $privileges (array)
+ */
+ public static function set_user_privileges($user, $privileges)
+ {
+ $db = Registry::use('database'); // database connection
+ foreach($privileges as $pri) { // pri = privilege id
+ $db->runQuery(
+ "INSERT INTO user_privilege (user_id, privilege_id) VALUES (:user, :pri)",
+ [ ':user' => $user, ":pri" => $pri ]
+ );
+ }
+ return true;
+ }
+
+
+ /** set_user_role
+ *
+ * user, role are (int) IDs
+ */
+ public static function set_user_role($user, $role)
+ {
+
+ Registry::use('database')->runQuery(
+ "INSERT INTO user_role (user_id, role_id) VALUES (:user, :role)",
+ [ ':user' => $user, ":role" => $role ]
+ );
+
+ return true;
+ }
+
+
+ /** activate
+ *
+ * check if activation code is valid;
+ * if valid, set account active;
+ *
+ * @param $ticket (hex/MD5): activation code;
+ *
+ */
+ public static function activate($ticket)
+ {
+ $user = Registry::use('database')->query(
+ "SELECT * FROM user WHERE activation = :ticket",
+ [ 'ticket' => $ticket ]
+ )->getFirst();
+
+ // if no user with this activation code, return false
+ if ($user === false) return false;
+
+ // remove activation code from user record
+ Registry::use('database')->runQuery(
+ "UPDATE user
+ SET active = 1, `activation` = NULL
+ WHERE activation = :ticket",
+ [ 'ticket' => $ticket ]
+ );
+
+ // update history
+ History::trackUserAccess($user['id'], TRACK_ACCOUNT, 'User Account Activated');
+
+ return true;
+
+ }
+
+
+}
+
+/* example query getUser (super-array)
+--- -- -- - - -
+
+SELECT user.*,
+( -- array (json) of roles granted to user
+ SELECT CONCAT('[', GROUP_CONCAT(role.id), ']')
+ FROM `role`
+ WHERE role.id IN (
+ SELECT user_role.role_id
+ FROM user_role
+ WHERE user_role.user_id = 1
+ )
+) AS Roles_json,
+(
+ SELECT CONCAT(
+ '[',
+ GROUP_CONCAT(privilege.id),
+ ']'
+ )
+ FROM privilege
+ WHERE privilege.id IN (
+ SELECT user_privilege.privilege_id
+ FROM user_privilege
+ WHERE user_privilege.user_id = 1
+ )
+) AS RootPrivileges_json,
+(
+ SELECT CONCAT( -- array of array of sub-privileges
+ '[',
+ GROUP_CONCAT( -- array (json) of subprivileges
+ (
+ SELECT CONCAT(
+ '[',
+ GROUP_CONCAT(included_id),
+ ']'
+ )
+ FROM privilege_includes
+ WHERE privilege_id = privilege.id
+ )
+ ),
+ ']'
+ )
+ FROM privilege
+ WHERE privilege.id IN (
+ SELECT user_privilege.privilege_id
+ FROM user_privilege
+ WHERE user_id = 1
+ )
+) AS SubPrivileges_json
+FROM user
+WHERE id = 1
+--- */ \ No newline at end of file
diff --git a/public/app/models/cms/Course_model.php b/public/app/models/cms/Course_model.php
new file mode 100644
index 0000000..6614993
--- /dev/null
+++ b/public/app/models/cms/Course_model.php
@@ -0,0 +1,314 @@
+<?php
+namespace app\models\cms;
+
+use \Registry;
+
+
+class Course_model {
+
+ /** COURSES
+ * -------------------------------------------------------------------------
+ */
+
+ /** get all categories
+ *
+ * raw table data (simplest SELECT)
+ *
+ */
+ public static function get_categories()
+ {
+ return Registry::use('database')->runQuery(
+ "SELECT * FROM course ORDER BY label",
+ []
+ );
+ }
+
+ /** courses struct
+ *
+ * a super array with almost any info needed about courses
+ *
+ * @return (array) ['tree' => ..., 'breadcrumbs' => ... ]
+ */
+ public static function courses_struct()
+ {
+ $tree = self::category_tree();
+ return [
+ 'tree' => $tree,
+ 'breadcrumbs' => self::breadcrumbs($tree)
+ ];
+ }
+
+
+ /** constuct a category_tree
+ *
+ * returns a tree representation of the categories
+ *
+ * NOTE:
+ * category_tree() is an expensive method;
+ * it calls 2 other methods implementing recursive algorithms
+ * thus it uses many sources to run (particularly RAM).
+ * Caching the result is strogly recommended.
+ *
+ */
+ public static function category_tree()
+ {
+ $categories = self::get_categories(); // get all categories
+
+ $tree = self::to_tree($categories); // format to a tree
+
+ $tree_wParents = self::tree_parents($tree); // add parents section for each tree-node
+
+ return $tree_wParents;
+ }
+
+
+ /** to_tree
+ *
+ * constructs a tree from raw-table data;
+ * this is a private method and uses a recursive algorithm
+ *
+ * @param $dataset (array): flar array of records with id/parent-id pairs
+ * @return $root (array): id of root category
+ *
+ * (**) each node has 2 parts:
+ * .... .. rec : all record attributes/data as passed into $dataset
+ * .... .. childs : array of (children) nodes
+ */
+ private static function to_tree($dataset, $root = 0)
+ {
+ $return = [];
+
+ // loop data ; search for direct children of root
+ foreach($dataset as $key => $rec) {
+
+ $child = $rec['id'];
+ $parent = $rec['parent_id'];
+
+ if ($parent == $root) { // a direct child is found
+
+ unset($dataset[$key]); // remove item (no need to traverse again)
+
+ // Append the child into result array ; parse its children
+ $return[] = [
+ 'rec' => [
+ 'id' => $rec['id'],
+ 'label' => $rec['label'],
+ 'order' => $rec['order'],
+ 'parent' => $rec['parent_id']
+ ],
+ 'childs' => self::to_tree($dataset, $child) // recursively
+ ];
+ }
+ }
+ return empty($return) ? [] : $return;
+ }
+
+
+ /** tree_parents
+ *
+ * adds a section to each tree node with all parents of each node
+ *
+ * @param $tree (array) : nodes array (each node has `rec` and `childs` sections )
+ * @param $parents (array); DO NOT SET IT (takes values automaticaly)
+ * @return array of nodes with an extra node[parents] section
+ *
+ */
+ private static function tree_parents($tree, $parents = [])
+ {
+ $tree_with_parents = [];
+
+ foreach($tree as $key => $node) {
+ // parents to be pushed for node's children
+ $push_parents = $parents; // parents so far
+ $push_parents[] = $node['rec']; // this record will be a new parent
+
+ $tree_with_parents[$key] = [
+ 'rec' => $node['rec'],
+ 'parents' => $parents,
+ 'childs' => ($node['childs'] == [])
+ ? []
+ : self::tree_parents($node['childs'], $push_parents)
+ ];
+ }
+
+ return $tree_with_parents;
+ }
+
+
+ /** all_breadcrumbs
+ * -------------------------------------------------------------------------
+ *
+ * returns an array of all breadcrumbs
+ * where array-key of each record is category[id]
+ *
+ * NOTE:
+ * ---
+ * Course_model::all_breadcrumbs returns an indexed super-array;
+ * each array item includes a banch of information: [
+ * breadcrumb,
+ * rec: [ id , title ],
+ * parents: [ [id, title] , ... ]
+ * childs: [ [id, title] , ... ],
+ * level
+ * ]
+ *
+ * Use Cases:
+ * ---
+ * as a super-array, the output can be used in many cases
+ * for example...
+ * into <select-option> form elements
+ * .. while selecting category for a post
+ * .. or editing a category
+ * or directry referring to category's parents/childs
+ *
+ * Arguments:
+ * ---
+ * @param $tree (array) : category tree (with childs and parents parts)
+ * @param $detimiter (string, optional) : string to split breadcrumb's path-nodes
+ * @param $exception (int, optional) : id of category to exclude (subcategories shall be excluded too)
+ * @param $l (int, not-pass) : depth level of the node; DO NOT SET (takes values automaticaly)
+ * @return array of breadcrumbs
+ * -------------------------------------------------------------------------
+ */
+ static public function breadcrumbs($tree, $delimiter = " / ", $exception = 0, $l = 0)
+ {
+ $all = []; // results array
+
+ foreach($tree as $node) { // loop through all nodes
+
+ if (intval($node['rec']['id']) != $exception) { // if node is not exception
+
+ // construct breadcrumb html of node
+ // --- -- -- - - -
+ $breadcrumb = "";
+ foreach($node['parents'] as $par) { // first: join path titles
+ $breadcrumb .= $par['label'] . $delimiter;
+ }
+ $breadcrumb .= $node['rec']['label']; // last: append title
+
+ // make a new super record
+ // --- -- -- - - -
+ $all[$node['rec']['id']] = [ // set record is as key
+ 'breadcrumb' => $breadcrumb, // add breadcrump to results
+ 'rec' => $node['rec'], // + node info
+ 'parents' => $node['parents'], // + parents array
+ 'childs' => self::first_level_childs($node), // + direct childs
+ 'level' => $l // + level
+ ];
+
+ // recursively traverse children nodes
+ // --- -- -- - - -
+ if (isset($node['childs']) && $node['childs'] != []) {
+ $child_breadcrumbs = self::breadcrumbs(
+ $node['childs'],
+ $delimiter,
+ $exception,
+ $l+1
+ );
+
+ $all = $all + $child_breadcrumbs; // concatenate arrays (keep array-keys)
+ }
+ }
+
+ }
+ return $all;
+
+ }
+
+ /** first_level_childs
+ * --- -- -- - - -
+ * used by all_breadcrumbs()
+ */
+ static private function first_level_childs($node)
+ {
+ $childs = [];
+ if ($node['childs'] == []) {
+ return [];
+ }
+ foreach($node['childs'] as $key => $kid) {
+ $childs[] = [
+ 'id' => $kid['rec']['id'],
+ 'label' => $kid['rec']['label']
+ ];
+ }
+ return $childs;
+ }
+
+
+
+
+ /** LESSONS
+ * -------------------------------------------------------------------------
+ */
+
+
+ /** lessons of course
+ *
+ * @param $id (int) : course_id
+ */
+ public static function lessons_of_course($id)
+ {
+ // TODO: order results in some way
+
+ return Registry::use('database')->runQuery(
+ "SELECT lesson.*, lesson_privilege.privilege_id FROM lesson
+ LEFT JOIN lesson_privilege ON lesson_privilege.lesson_id = lesson.id
+ WHERE lesson.status = 1 AND lesson.course_id = :id
+ ORDER BY lesson_privilege.privilege_id ASC",
+ [':id' => $id]
+ );
+ }
+
+ /** lesson
+ *
+ * @param $id (int) : lesson id
+ */
+ public static function lesson($id)
+ {
+ // TODO: order results in some way
+
+ return Registry::use('database')->query(
+ "SELECT lesson.*, lesson_privilege.privilege_id FROM lesson
+ LEFT JOIN lesson_privilege ON lesson_privilege.lesson_id = lesson.id
+ WHERE lesson.status = 1 AND lesson.id = :id",
+ [':id' => $id]
+ )->getFirst();
+ }
+
+
+
+ /** files
+ * return all files
+ * @param void
+ * @return array
+ */
+ public static function files()
+ {
+ return Registry::use('database')->runQuery("SELECT * from media", []);
+ }
+
+
+ /** pages
+ *
+ * return all pages as array;
+ * array key of each item shall be the page-id
+ *
+ * @param void
+ * @return array
+ */
+ public static function pages()
+ {
+ $result = [];
+ $pages = Registry::use('database')->runQuery("SELECT * FROM page", []);
+ foreach($pages as $key => $page) {
+ $result[ $page['id'] ] = [
+ 'title' => $page['title'],
+ 'body' => $page['body'],
+ 'status' => $page['status']
+ ];
+ }
+ // print_r($result); die();
+ return $result;
+ }
+
+}
diff --git a/public/app/models/cms/Media_model.php b/public/app/models/cms/Media_model.php
new file mode 100644
index 0000000..6a4a681
--- /dev/null
+++ b/public/app/models/cms/Media_model.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace app\models\cms;
+
+use \Registry;
+
+
+class Media_model
+{
+ public static function serve($type, $id, $ticket)
+ {
+
+ // get media data
+ // [ type =>, rights =>, path => ]
+
+ // validate ticket
+ // ValidateAccess::for($ticket)
+
+ // serve
+ // header("Content-type: type");
+ // passthru('cat $media_path');
+
+
+ return Registry::use('database')->query(
+ "SELECT * FROM pages
+ WHERE FullFriendlyUrl = :url AND IsActive = 1",
+ [ ':url' => $url ]
+ )->getFirst();
+ }
+
+}
+
+
+
+// check:
+// https://stackoverflow.com/questions/1353850/serve-image-with-php-script-vs-direct-loading-an-image \ No newline at end of file
diff --git a/public/app/models/cms/Page_model.php b/public/app/models/cms/Page_model.php
new file mode 100644
index 0000000..fda5fb1
--- /dev/null
+++ b/public/app/models/cms/Page_model.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace app\models\cms;
+
+use \Registry;
+
+class Page_model
+{
+ public static function fromUrl($url)
+ {
+ return Registry::use('database')->query(
+ "SELECT * FROM pages
+ WHERE FullFriendlyUrl = :url AND IsActive = 1",
+ [ ':url' => $url ]
+ )->getFirst();
+ }
+
+} \ No newline at end of file
diff --git a/public/app/models/market/Market_repository.php b/public/app/models/market/Market_repository.php
new file mode 100644
index 0000000..1338426
--- /dev/null
+++ b/public/app/models/market/Market_repository.php
@@ -0,0 +1,235 @@
+<?php
+
+namespace app\models\market;
+
+use \Repository;
+
+/** Market_repository
+ * (thematic-entity repository)
+ * ---
+ * SQL entity templates for Market
+ *
+ * Every '_base' is a repository of common 'prepare' SQL-queries;
+ * They usually include holders for attaching/binding data safely;
+ * These queries are expensive in syntax but fast on run time and
+ * are used to collect multi-joined amounts ot data.
+ *
+ * Organize yous repositories in logical/thematic groups (partitioning).
+ * This way your repository is managable and uses very few sources.
+ *
+ * This class acts like a namespaced repository for geting your code readable
+ * Avoid pushing very simple queries into repository classes. A record like...
+ * 'CARS_by:Age' => "SELECT * FROM cars WHERE age = :Age"
+ * ... won't make your code sexier nor easier to read; it will just increase
+ * your label's entropy and make trickier to pick your next sensable label;
+ *
+ * repository classes include two methods (inherited by the abstract, so
+ * you do not need to implement something more than just construct
+ * the $repository array)
+ * ** pull: for pulling a query by a friendly name
+ * ** echo: optional method used by some devolopment tools
+ *
+ * TODO: development tool using echo is still in progress
+ */
+class Market_repository extends Repository
+{
+ //// use Repository; // repository trait includes
+ //// // a method to pull an entity of the gathering
+ //// // and method to (TODO:) ...
+
+ /** repository is an array of Prepare SWL queries;
+ * each query is attached to the array via a friendly label;
+ *
+ * NOTE: (PROPOSAL)
+ * ---
+ * about the label:
+ * 1. keep names short descriptive but not too long
+ * 2. main entity/entities shall be UPPERCASED
+ * 3. determinands/adjectives shall be Cappitalized
+ * 4. words are connected with underscore (_)
+ * 5. if SQL inludes bind-holderd, label shall be suffixed with '_by:'
+ * followed by holder names connected with undercores (case-sensitively)
+ *
+ * about the data-bind holders (if present):
+ * 1. all holders are prefixed with ':'
+ * 2. holder name shall begin with a letter and contain one word.
+ *
+ * example:
+ * 'Active_EMPLOYEES_by:Age_CityID' => "SELECT Em.*, c.*
+ * FROM employee Em LEFT JOIN city c ON c.id = Em.city_id
+ * WHERE c.id = :CityID AND Em.age = :Age"
+ *
+ * It is recommended to leave a descriptive comment befor each declaration
+ * and you may also include sql-comments inside the query.
+ */
+ public static $repository = [
+
+ // PRODUCTS by: storeID, ProductUrl
+ // picks all product properties
+ // + default image + price for certain store
+ 'Active_PRODUCT_by:storeID_ProductUrl' =>
+ "SELECT p.*,
+ pc.ID AS ProductCategory,
+ pc.Hierarchy,
+ pc.FullFriendlyUrl AS `Path`,
+ prices.ProductPrice_OriginalPrice AS Price,
+ prices.ProductPrice_DiscountedPrice AS DiscountPrice,
+ prices.UnitMeasurePrice_OriginalPrice AS PerUnitPrice,
+ prices.UnitMeasurePrice_DiscountedPrice AS PerUnitDiscountPrice,
+ b.Title BrandName
+ FROM products p
+ LEFT JOIN products_to_product_categories p2pc ON p.ID = p2pc.SimpleProductID
+ LEFT JOIN product_categories pc ON p2pc.ProductCategoryID = pc.ID
+ LEFT JOIN prices ON p.SKU = prices.SKU
+ LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ LEFT JOIN assets ON assets.ID = p2i.ImageID
+ LEFT JOIN brands b ON b.ID = p.BrandID
+ WHERE pc.IsActive = 1 AND pc.IsCurrentlyActive = 1
+ AND p2i.Order = 1
+ AND p.IsActive = 1
+ AND p.Published = 1
+ AND prices.PriceListID = :storeID
+ AND p.FriendlyUrl = :ProductUrl"
+ ,
+
+ // PRODUCTS by: storeID, ProductID
+ // picks all product properties
+ // + default image + price for certain store
+ 'Active_PRODUCT_by:storeID_ProductID' =>
+ "SELECT p.*,
+ pc.ID AS ProductCategory,
+ pc.Hierarchy,
+ pc.FullFriendlyUrl AS `Path`,
+ prices.ProductPrice_OriginalPrice AS Price,
+ prices.ProductPrice_DiscountedPrice AS DiscountPrice,
+ prices.UnitMeasurePrice_OriginalPrice AS PerUnitPrice,
+ prices.UnitMeasurePrice_DiscountedPrice AS PerUnitDiscountPrice,
+ b.Title BrandName
+ FROM products p
+ LEFT JOIN products_to_product_categories p2pc ON p.ID = p2pc.SimpleProductID
+ LEFT JOIN product_categories pc ON p2pc.ProductCategoryID = pc.ID
+ LEFT JOIN prices ON p.SKU = prices.SKU
+ LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ LEFT JOIN assets ON assets.ID = p2i.ImageID
+ LEFT JOIN brands b ON b.ID = p.BrandID
+ WHERE pc.IsActive = 1 AND pc.IsCurrentlyActive = 1
+ AND p2i.Order = 1
+ AND p.IsActive = 1
+ AND p.Published = 1
+ AND prices.PriceListID = :storeID
+ AND p.ID = :ProductID"
+ ,
+
+ // CATEGORY-PRODUCTS by: storeID, likeHierarchy
+ // traverses all products from category and subcategories
+ // picke price and default image
+ 'CATEGORY-PRODUCTS_by:storeID_likeHierarchy' =>
+ "SELECT p.*,
+ assets.Url AS ImageUrl,
+ pc.FullFriendlyUrl AS `Path`,
+ prices.ProductPrice_OriginalPrice AS Price,
+ prices.ProductPrice_DiscountedPrice AS DiscountPrice,
+ prices.UnitMeasurePrice_OriginalPrice AS PerUnitPrice,
+ prices.UnitMeasurePrice_DiscountedPrice AS PerUnitDiscountPrice
+ FROM products p
+ LEFT JOIN prices ON p.SKU = prices.SKU
+ LEFT JOIN products_to_product_categories p2pc ON p2pc.SimpleProductID = p.ID
+ LEFT JOIN product_categories pc ON pc.ID = p2pc.ProductCategoryID
+ LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ LEFT JOIN assets ON assets.ID = p2i.ImageID
+ WHERE p2pc.ProductCategoryID IN (
+ SELECT `ID` FROM product_categories
+ WHERE Hierarchy LIKE :likeHierarchy
+ )
+ AND p2i.Order = 1
+ AND p.IsActive = 1
+ AND p.Published = 1
+ AND prices.PriceListID = :storeID"
+ ,
+
+ // Count Products
+ // of all last-lever Product categories
+ // by StoreID
+ // ---
+ // used on creating product_categories tree
+ // ProductCategories_model::tree()
+ 'Count_Last_Level_PRODUCTS_by:storeID' =>
+ "SELECT pc.ID, COUNT(p.ID) as `Counter`
+ FROM product_categories pc
+ LEFT JOIN products_to_product_categories p2pc ON pc.ID = p2pc.ProductCategoryID
+ LEFT JOIN products p ON p2pc.SimpleProductID = p.ID
+ LEFT JOIN prices ON p.SKU = prices.SKU
+ LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ LEFT JOIN assets ON assets.ID = p2i.ImageID
+ WHERE pc.IsActive = 1 AND pc.IsCurrentlyActive = 1
+ AND p2i.Order = 1
+ AND pc.Level = 2
+ AND p.IsActive = 1
+ AND p.Published = 1
+ AND prices.PriceListID = :storeID
+ GROUP BY pc.ID"
+ ,
+
+ // PRODUCT by: ProductID
+ // Join images array (json string)
+ // Join prices for all stores (json string)
+ 'Active_PRODUCT_complete_by:ProductID' =>
+ "SELECT p.*, pc.ID AS ProductCategory,
+ pc.Hierarchy, pc.FullFriendlyUrl AS `Path`,
+ ( -- get all store prices as Json
+ SELECT JSON_ARRAYAGG(JSON_OBJECT(
+ 'Store', prices.PriceListID,
+ 'Price', prices.ProductPrice_OriginalPrice,
+ 'DiscountPrice', prices.ProductPrice_DiscountedPrice,
+ 'PerUnitPrice', prices.UnitMeasurePrice_OriginalPrice,
+ 'PerUnitDiscountPrice', prices.UnitMeasurePrice_DiscountedPrice
+ ))
+ FROM prices
+ WHERE prices.SKU = p.SKU
+ ) as pricesJson,
+ ( -- get all images as Json
+ SELECT JSON_ARRAYAGG(JSON_OBJECT(
+ 'Order', p2i.Order,
+ 'Url', assets.Url,
+ 'Title', assets.Title,
+ 'Description', assets.Description
+ ))
+ FROM products_to_images p2i
+ LEFT JOIN assets ON assets.ID = p2i.ImageID
+ WHERE p2i.SimpleProductID = p.ID
+ ) AS imagesJson,
+ b.Title BrandName
+ FROM products p
+ LEFT JOIN products_to_product_categories p2pc ON p.ID = p2pc.SimpleProductID
+ LEFT JOIN product_categories pc ON p2pc.ProductCategoryID = pc.ID
+ LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ LEFT JOIN assets ON assets.ID = p2i.ImageID
+ LEFT JOIN brands b ON b.ID = p.BrandID
+ WHERE pc.IsActive = 1 AND pc.IsCurrentlyActive = 1
+ AND p2i.Order = 1
+ AND p.IsActive = 1
+ AND p.Published = 1
+ AND p.ID = :ProductID"
+ ,
+
+ // Product images by: ProductID
+ // (sorted by 'order' field)
+ 'PRODUCT_IMAGES_by:productID' =>
+ "SELECT a.Url, a.Title, a.Description
+ FROM products p
+ LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ LEFT JOIN assets a ON a.ID = p2i.ImageID
+ WHERE p.IsActive = 1
+ AND p.Published = 1
+ AND p.ID = :productID
+ ORDER BY p2i.Order"
+
+ ];
+
+ /* no need to impement anything else;
+ * you can overide if needed the default methods:
+ * + public static function pull($entity){ }
+ * + public static function echo($content =false){ }
+ */
+
+}
diff --git a/public/app/models/market/Payment_template.txt b/public/app/models/market/Payment_template.txt
new file mode 100644
index 0000000..4a66a1a
--- /dev/null
+++ b/public/app/models/market/Payment_template.txt
@@ -0,0 +1,98 @@
+class WC_Piraeusbank_Gateway extends WC_Payment_Gateway {
+
+ public function __construct()
+
+ function pb_get_pages($title = false, $indent = true)
+
+ function pb_get_installments($title = false, $indent = true)
+
+ function generate_piraeusbank_form($order_id)
+
+ function process_payment($order_id)
+
+ function receipt_page($order)
+
+ function check_piraeusbank_response()
+
+ function piraeusbank_message()
+
+ function woocommerce_add_piraeusbank_gateway($methods)
+
+ function piraeusbank_plugin_action_links($links, $file)
+
+}
+
+
+
+class WC_NBG_Gateway extends WC_Payment_Gateway {
+
+ public function __construct()
+
+ public function admin_options()
+
+ function init_form_fields()
+
+ function nbg_get_pages($title = false, $indent = true)
+
+ function nbg_get_installments($title = false, $indent = true)
+
+ function generate_nbg_form($order_id)
+
+ function process_payment($order_id)
+
+ function receipt_page($order) {
+
+ function check_nbg_response()
+
+ function nbg_message()
+
+ function woocommerce_add_nbg_gateway($methods)
+
+ function nbg_plugin_action_links($links, $file)
+
+}
+
+
+
+global $wc;
+
+ $this->id = 'nbg_gateway';
+ $this->icon = apply_filters('nbg_icon', plugins_url('assets/nbg.png', __FILE__));
+ $this->has_fields = false;
+ $this->notify_url = WC()->api_request_url('WC_NBG_Gateway');
+ $this->method_description = __('National Bank Greece Payment Gateway allows you to accept payment through various channels such as Maestro, Mastercard and Visa cards On your Woocommerce Powered Site.', 'woocommerce-nbg-payment-gateway');
+ $this->redirect_page_id = $this->get_option('redirect_page_id');
+ $this->method_title = 'National Bank of Greece Gateway';
+
+ // Load the form fields.
+ $this->init_form_fields();
+
+ //dhmioyrgia vashs
+
+ global $wpdb;
+
+ if ($wpdb->get_var("SHOW TABLES LIKE '" . $wpdb->prefix . "nbg_transactions'") === $wpdb->prefix . 'nbg_transactions') {
+ // The database table exist
+ } else {
+ // Table does not exist
+ $query = 'CREATE TABLE IF NOT EXISTS ' . $wpdb->prefix . 'nbg_transactions (id int(11) unsigned NOT NULL AUTO_INCREMENT,merchantreference varchar(30) not null, reference varchar(100) not null, orderid varchar(100) not null , timestamp datetime default null, PRIMARY KEY (id))';
+ $wpdb->query($query);
+ }
+
+
+ // Load the settings.
+ $this->init_settings();
+
+
+ // Define user set variables
+ $this->title = $this->get_option('title');
+ $this->description = $this->get_option('description');
+ $this->nbg_Username = $this->get_option('nbg_Username');
+ $this->nbg_Password = $this->get_option('nbg_Password');
+
+ $this->nbg_description= $this->get_option('nbg_description');
+ $this->mode = $this->get_option('mode');
+ $this->nbg_installments= $this->get_option('nbg_installments');
+ //Actions
+ add_action('woocommerce_receipt_nbg_gateway', array($this, 'receipt_page'));
+ add_action('woocommerce_update_options_payment_gateways_' . $this->id, array($this
diff --git a/public/app/models/market/ProductCategories_model.php b/public/app/models/market/ProductCategories_model.php
new file mode 100644
index 0000000..d8c274e
--- /dev/null
+++ b/public/app/models/market/ProductCategories_model.php
@@ -0,0 +1,199 @@
+<?php
+
+namespace app\models\market;
+
+use \Registry;
+use app\models\market\Market_repository;
+
+
+/** ProductCategories_model
+ * ---
+ * undertakes to collect any product_categories data
+ * from the database and prepare needed data structures.
+ *
+ */
+class ProductCategories_model
+{
+
+ /** Get (product_) category from (full-friendly_) URL
+ * --- (self explanatory)
+ * @param $url (string): Full-Friendly-URL
+ * @return $category (array); also includes all category products
+ */
+ public static function categoryFromUrl($url)
+ {
+ $db = Registry::use('database');
+ $category = $db->query( "SELECT * FROM product_categories
+ WHERE FullFriendlyUrl = :url AND IsActive = 1",
+ [ ':url' => $url ]
+ )->getFirst();
+
+ if ($category === false) { return false; } // no category? -> false
+
+ // GET PRODUCTS of category
+ $category['products'] = self::categoryProductsByHierarchy($category['Hierarchy']);
+ return $category;
+ }
+
+
+ /** Get (product_) category from ID
+ * --- (self explanatory)
+ * @param $id (int): Catgory ID (usualy from products)
+ * @return $category (array); also includes all category products
+ */
+ public static function categoryFromID($id)
+ {
+ return Registry::use('database')->query( "SELECT * FROM product_categories
+ WHERE `ID` = :url AND IsActive = 1",
+ [':id' => $id]
+ )->getFirst();
+
+ }
+
+
+ /** categoryProductsByHierarchy
+ * ---
+ * This method returns the products belonging to a certain
+ * category (and all it's the children/sub-categories);
+ * It uses category.Hierarchy in a WHERE LIKE condition to
+ * make it fast.
+ *
+ * The method is category.Level agnostic
+ *
+ * @param $category (array)
+ * @return $products (array)
+ */
+ public static function categoryProductsByHierarchy($hierarchy, $store = 904)
+ {
+ // GET PRODUCTS of category
+ // NOTE:
+ // category.Hierarchy is used
+
+ return Registry::use('database')->runQuery(
+ Market_repository::pull('CATEGORY-PRODUCTS_by:storeID_likeHierarchy'),
+ [
+ ':likeHierarchy' => $hierarchy.'%',
+ ':storeID' => 904
+ ]
+ );
+ }
+
+
+ # depricated:
+ # now Proxy has the responsibility to cache whatever
+ # ---
+ # /** GET tree of product_categories
+ # * ---
+ # * get from cache or cache it after creation
+ # */
+ # public static function tree($store = 904)
+ # {
+ # $cacheKey = get_called_class() . $store .'/tree';
+ #
+ # // IF category CACHED
+ # $cache = Registry::use('cache');
+ # $tree = $cache->get($cacheKey);
+ # if ($tree !== false) {
+ # return $tree;
+ # }
+ #
+ # // (not cached) Create and Cache it
+ # $tree = self::createCategoriesTree();
+ # $cache->set($cacheKey, $tree, CACHE_ROOT_TTL);
+ #
+ # return $tree;
+ # }
+
+
+ /** CREATE product_categories tree
+ * NOTE: includes active categories only;
+ *
+ * Algorith uses 'Hierarchy' for category path-positioning,
+ * implementing a linear conctruction of the requested tree;
+ * (faster and less source-consuming than a recursive algo)
+ */
+ public static function tree($store = 904)
+ {
+ $tree = []; // variable to hold results
+
+ $db = Registry::use('database');
+
+ // get raw categories data ---------------------------------------------
+
+ $raw = $db->runQuery( "SELECT `ID`, Title, `Level`, Hierarchy,
+ ParentID, `Order`, FullFriendlyUrl
+ FROM product_categories pc
+ WHERE IsActive = 1 AND IsCurrentlyActive = 1
+ ORDER BY pc.Level asc, pc.Order asc, pc.Hierarchy asc",
+ []
+ );
+
+ // count products per 3rd level category -------------------------------
+
+ $countProducts = $db->runQuery(
+ Market_repository::pull('Count_Last_Level_PRODUCTS_by:storeID'),
+ [ ':storeID' => $store ]
+ );
+ // map categoryID -> Counter
+ $mapCounter = [];
+ foreach($countProducts as $rec) $mapCounter[$rec['ID']] = $rec['Counter'];
+
+ // parse raw data to hierarchical tree (2 pass) ------------------------
+
+ // 1st pass: parse raw data, construct and fill the $tree array ........
+ foreach($raw as $rec) {
+
+ // create category path from Hierarchy (format: .10.10100. )
+ // so remove 1st and last dots (.) from Hierarchy string
+ // then explode via dot-character (.)
+ $categoryPath = explode('.', substr($rec['Hierarchy'], 1, -1));
+
+ switch ($rec['Level']) {
+ case 0:
+ $tree[$categoryPath[0]] = [
+ 'info' => $rec,
+ 'childs' => []
+ ]; break;
+
+ case 1:
+ $tree[$categoryPath[0]]['childs'][$categoryPath[1]] = [
+ 'info' => $rec,
+ 'childs' => []
+ ]; break;
+
+ case 2:
+ $tree[$categoryPath[0]]['childs'][$categoryPath[1]]['childs'][$categoryPath[2]] = [
+ 'info' => $rec,
+ 'count' => isset($mapCounter[$categoryPath[2]]) ? $mapCounter[$categoryPath[2]] : 0
+ ]; break;
+
+ default:
+ // nothing...
+ }
+
+ }
+
+ // 2nd pass: remove categories with no products ........................
+ foreach($tree as $id0 => $rec0) {
+ $count0 = 0;
+
+ foreach($rec0['childs'] as $id1 => $rec1) {
+ $count1 = 0;
+
+ foreach($rec1['childs'] as $id2 => $rec2) {
+ if ($rec2['count']>0) $count1++;
+ else unset($tree[$id0]['childs'][$id1]['childs'][$id2]);
+ }
+
+ if ($count1 > 0) $count0++;
+ else unset($tree[$id0]['childs'][$id1]);
+ }
+
+ if (!$count0) unset($tree[$id0]);
+ }
+
+ return $tree;
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/models/market/Product_model.php b/public/app/models/market/Product_model.php
new file mode 100644
index 0000000..aa554a1
--- /dev/null
+++ b/public/app/models/market/Product_model.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace app\models\market;
+
+use \Registry;
+use app\models\market\Market_repository as Market;
+
+class Product_model
+{
+ public static function fromUrl($url, $store = 904)
+ {
+ $product = Registry::use('database')->query(
+ Market::pull('Active_PRODUCT_by:storeID_ProductUrl'),
+ [
+ ':storeID' => $store,
+ ':ProductUrl' => $url
+ ]
+ )->getFirst();
+
+ // if no product, return false
+ if ($product === false) return false;
+
+ // inject product's images
+ $product['Images'] = self::getProductImages($product['ID']);
+
+ return $product;
+ }
+
+
+ public static function getProductImages($id)
+ {
+ ## return Registry::use('database')->runQuery(
+ ## "SELECT a.Url, a.Title, a.Description
+ ## FROM products p
+ ## LEFT JOIN products_to_images p2i ON p2i.SimpleProductID = p.ID
+ ## LEFT JOIN assets a ON a.ID = p2i.ImageID
+ ## WHERE p.IsActive = 1 AND p.Published = 1
+ ## AND p.ID = :id
+ ## ORDER BY p2i.Order",
+ ## [ ':id' => $id ]
+ ## );
+
+ return Registry::use('database')->runQuery(
+ Market::pull('PRODUCT_IMAGES_by:productID'),
+ ['productID' => $id ]
+ );
+ }
+
+} \ No newline at end of file
diff --git a/public/app/models/todo.md b/public/app/models/todo.md
new file mode 100644
index 0000000..defd21e
--- /dev/null
+++ b/public/app/models/todo.md
@@ -0,0 +1,132 @@
+
+
+Privilege
+---
+
+Privilege::list_of_privileges( privilege_id )
+
+Privilege::privileges_tree()
+
+
+
+
+
+User
+---
+
+User::get_privileges
+
+
+User::has_privilege( privilege_id )
+
+
+User::reset_password()
+ -> create otp
+ -> send email
+
+
+User::set_privilege( privilege_id )
+
+
+User::track_action()
+ -> user_id
+ -> action : Contoller::method(args)
+ -> timestamp
+
+
+
+Lesson:
+---
+
+Lesson::create_lesson( POST )
+
+Lesson::get_lesson( lesson_id )
+
+
+
+Course
+---
+
+Course::create_course( POST )
+
+Course::courses_tree()
+
+
+
+Upgrades (??)
+---
+
+
+A1
+
+A2 : includes A1
+
+A3 : includes A1, A2
+
+A4 : includes A1, A2, A3
+
+
+A1 100
+A2 200
+A3 350
+A4 500
+
+B1 100
+B2 200
+B3 400
+B4 800
+
+
+:1 -> 0%
+:2 -> 15%
+:3 -> 20%
+
+
+
+
+A1 -> A2
+A1 -> A3
+A1 -> A4
+
+A2 -> A3
+A2 -> A4
+
+A3 -> A4
+
+A1 < A2 < A3 < A4
+
+
+
+B1 -> B2
+B1 -> B3
+B1 -> B4
+
+B2 -> B3
+B2 -> B4
+
+B3 -> B4
+
+
+A1+B1
+
+A2+B2
+
+A3+B3
+
+
+A1+B1 -> A2+B2
+A1+B1 -> A3+B3
+A1+B1 -> A4+B4
+
+A2+B2 -> A3+B3
+A2+B2 -> A4+B4
+
+A3+B3 -> A4+B4
+
+
+
+UPGRADE PRICE = upper_priv_price - own_priv_price
+
+buy 1 item : -0%
+buy 2 items : -10%
+buy 3 items : -15%
diff --git a/public/app/routes/.gitkeep b/public/app/routes/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/routes/.gitkeep
diff --git a/public/app/routes/api.php b/public/app/routes/api.php
new file mode 100644
index 0000000..61e485b
--- /dev/null
+++ b/public/app/routes/api.php
@@ -0,0 +1,81 @@
+<?php
+
+use app\controllers\Auth;
+use app\controllers\api\Common_api;
+use app\controllers\api\Doc_api;
+
+
+/** API CALLS
+ * -----------------------------------------------------------------------------
+ *
+ * these calls return json responses
+ */
+
+
+// api: tree of product-categories
+Route::add('/api/tree',
+ function() { Doc_api::tree(); },
+ 'get'
+);
+
+// api: database documentation
+Route::add('/api/doc',
+ function() { Doc_api::dbDoc(); },
+ 'get'
+);
+
+Route::add('/api/url/([0-9a-zA-Z-_\/]*)',
+ function($url) { Common_api::category_by_url($url); }
+);
+
+// route: /api/table
+Route::add('/api/([0-9a-zA-Z-_]*)',
+ function($table) { Common_api::table($table); },
+ 'get'
+);
+
+// route: /api/table/{id}
+Route::add('/api/([0-9a-zA-Z-_]*)/([0-9]*)',
+ function($table, $id) { Common_api::record($table, $id); },
+ 'get'
+);
+
+
+
+/** XML CALLS
+ * -----------------------------------------------------------------------------
+ *
+ * These calls are like `/api`, except...
+ * they return xml/html as part of the responses;
+ *
+ * example: { success: true, message: html }
+ *
+ * Used when an html message needs to be passed in some div
+ *
+ */
+
+
+
+
+/** TEST CALLS
+ * -----------------------------------------------------------------------------
+ *
+ * direct calls to simple tests
+ *
+ */
+
+
+// route for testing...
+// ---
+Route::add('/test/([0-9a-zA-Z-_\/]*)', function($test) {
+
+ // TODO:
+ // hide tests from production
+
+ if (file_exists(TESTS_DIRECTORY . $test .'.php')) {
+ if (!Benchmark::exist('start')) Benchmark::add_spot('start');
+ require(TESTS_DIRECTORY . $test .'.php');
+ } else { echo "Test {$test} not exist";
+ } die();
+
+});
diff --git a/public/app/routes/backend.php b/public/app/routes/backend.php
new file mode 100644
index 0000000..2bbd056
--- /dev/null
+++ b/public/app/routes/backend.php
@@ -0,0 +1,221 @@
+<?php
+
+use app\controllers\Auth;
+use app\controllers\admin\Course_admin;
+use app\models\admin\Privilege_model;
+use app\extends\Cache_service;
+
+
+/** admin pages
+ * -----------------------------------------------------------------------------
+ * NOTE:
+ * All adminitration routes MUST INCLUDE an Auth::allowRoles() check
+ */
+
+// admin panel
+////////////////////////////////////////////////////////////////////////////////
+
+Route::add('/admin', function () { // admin panel
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Classroom',
+ 'action' => 'panel',
+ ]);
+});
+Route::add('/admin/panel', function () { // admin panel #2
+ Auth::allowRoles([1, 2, 3]);
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Classroom',
+ 'action' => 'panel',
+ ]);
+});
+
+
+// admin categories (=courses)
+////////////////////////////////////////////////////////////////////////////////
+
+
+Route::add('/admin/categories', function () { // manage categories page
+ Auth::allowRoles([1, 2, 3]);
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Κεφαλαίων',
+ 'action' => 'categories',
+ ]);
+});
+
+Route::add('/admin/api/categories', function () { // ajax: get all courses; with breadcrumbs
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Course_admin::breadcrumbs();
+});
+
+Route::add('/admin/api/categories/add', function () { // ajax: add new category (course)
+ Auth::allowRoles([1, 2, 3]);
+ Course_admin::add_course();
+}, 'post');
+
+Route::add('/admin/api/categories/update', function () { // ajax: edit category (course)
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Course_admin::update_course();
+},
+'post');
+
+
+// admin privileges
+////////////////////////////////////////////////////////////////////////////////
+
+
+Route::add('/admin/privileges', function () { // manage privileges page
+ Auth::allowRoles([1, 2, 3]);
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Δικαιωμάτων Πρόσβασης',
+ 'action' => 'privileges',
+ ]);
+});
+
+// ajax: get all privileges (courses; with breadcrumbs)
+// use proxy (cache results); send as json
+Route::add('/admin/api/privileges', function () {
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::json( Cache_service::privileges_hierarchy() );
+});
+
+// add privilege
+
+// update privilege
+
+
+
+// admin lessons
+////////////////////////////////////////////////////////////////////////////////
+
+Route::add('/admin/lessons', function () { // request admin-lessons page
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Μαθημάτων',
+ 'action' => 'lessons',
+ ]);
+});
+
+Route::add('/admin/edit_lesson', function () { // new lesson form
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::view('admin/skeleton', [
+ 'title' => 'Νέο Μάθημα',
+ 'action' => 'edit_lesson',
+ ]);
+});
+
+Route::add('/admin/edit_lesson/([0-9]*)', function ($id) { // edit lesson form
+ Auth::allowRoles([1, 2, 3]);
+ Render::view('admin/skeleton', [
+ 'title' => 'Επεξεργασία Μαθήματος',
+ 'action' => 'edit_lesson',
+ 'id' => intval($id)
+ ]);
+});
+
+
+Route::add('/admin/api/lesson/([0-9]*)', function ($id) { // ajax: get lesson
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Course_admin::get_lesson($id);
+});
+
+
+Route::add('/admin/api/lesson/add', function () { // ajax: add lesson
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::json(['success' => Course_admin::add_lesson()]);
+}, 'post');
+
+
+Route::add('/admin/api/lesson/update', function () { // ajax: update lesson
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::json(['success' => Course_admin::update_lesson()]);
+}, 'post');
+
+
+Route::add('/admin/api/lessons', function () { // ajax: get all lessons
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Course_admin::all_lessons();
+});
+
+
+
+// admin files
+////////////////////////////////////////////////////////////////////////////////
+
+Route::add('/admin/files', function () { // admin-files panel
+ Auth::allowRoles([1, 2, 3]);
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Μέσων (media)',
+ 'action' => 'files',
+ ]);
+});
+
+
+Route::add('/admin/api/files', function () { // ajax: get lesson
+ Auth::allowRoles([1, 2, 3]);
+ Render::json(Course_admin::files());
+});
+
+// ajax: file-upload
+// ---
+Route::add('/admin/api/file_upload', function () {
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::json(Course_admin::upload_file());
+}, 'post');
+
+
+
+
+// admin page
+////////////////////////////////////////////////////////////////////////////////
+
+Route::add('/admin/pages', function () { // request admin-lessons page
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::view('admin/skeleton', [
+ 'title' => 'Διαχείριση Σελίδων',
+ 'action' => 'pages',
+ ]);
+});
+
+
+Route::add('/admin/edit_page', function () { // new page form
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::view('admin/skeleton', [
+ 'title' => 'Νέα Σελίδα',
+ 'action' => 'edit_page',
+ ]);
+});
+
+Route::add('/admin/edit_page/([0-9]*)', function ($id) { // edit page form
+ Auth::allowRoles([1, 2, 3]);
+ Render::view('admin/skeleton', [
+ 'title' => 'Επεξεργασία Σελίδας',
+ 'action' => 'edit_page',
+ 'id' => intval($id)
+ ]);
+});
+
+
+Route::add('/admin/api/page/([0-9]*)', function ($id) { // ajax: get page
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Course_admin::get_page($id);
+});
+
+
+Route::add('/admin/api/page/add', function () { // ajax: add page
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::json(['success' => Course_admin::add_page()]);
+}, 'post');
+
+
+Route::add('/admin/api/page/update', function () { // ajax: update page
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Render::json(['success' => Course_admin::update_page()]);
+}, 'post');
+
+
+Route::add('/admin/api/pages', function () { // ajax: get all pages
+ Auth::allowRoles([1, 2, 3]); // allow few admin-panel roles
+ Course_admin::all_pages();
+});
+
diff --git a/public/app/routes/frontend.php b/public/app/routes/frontend.php
new file mode 100644
index 0000000..be06b1c
--- /dev/null
+++ b/public/app/routes/frontend.php
@@ -0,0 +1,169 @@
+<?php
+
+use app\controllers\Auth;
+use app\controllers\Classroom_user;
+use app\controllers\cms\Course;
+use app\models\cms\Course_model;
+use app\extends\Cache_service;
+
+
+// Home/Welcome
+////////////////////////////////////////////////////////////////////////////////
+
+Route::add('/', function() {
+ Render::view('welcome', [
+ 'categories' => Cache_service::courses_struct()['tree'],
+ 'entity' => Cache_service::pages_list()
+ ]);
+ // need redirect?... header('Location: /some/default/url', true, 302);
+});
+
+
+// Error pages
+////////////////////////////////////////////////////////////////////////////////
+
+Route::notFound( function() { // 404 error page
+ header("HTTP/1.0 404 Not Found");
+ Render::view('error/404', ['message' => 'nobody knows it (but you got a secret smile;)']);
+});
+
+
+
+// Common content
+////////////////////////////////////////////////////////////////////////////////
+
+
+Route::add('/course/([0-9]*)', // request course by course-id
+ function($id) { Course::course($id); }
+);
+
+Route::add('/lesson/([0-9]*)', // request lesson by lesson-id
+ function($id) { Course::lesson($id); }
+);
+
+Route::add('/page/([0-9]*)', // request lesson by lesson-id
+ function($id) { Course::page($id); }
+);
+
+Route::add('/serve/file/([0-9a-zA-Z-_\.\/]*)', // serve file by path
+ function ($path) { Course::serve_file($path); } // (also ?type= is sent)
+);
+
+
+
+
+
+// Account
+///////////////////////////////////////////////////////////////////////////////////////////////
+
+// [ok] login
+// [ok] register
+// [ok] activate
+// [..] ask-reset
+// [..] reset
+// [..] profile
+// [..] edit-profile
+// [..] subscriptions
+// [..] payments
+// [..] pay
+// [..] order
+
+
+
+
+/** URL-format Proposals and common Route regex solutions
+ * -----------------------------------------------------------------------------
+ *
+ * Page (almost-static content):
+ * /about/{url}
+# Route::add('/about/([0-9a-zA-Z-_\/]*)', function($url) { Page::fromUrl($url); });
+ *
+ * Product:
+ * /{level-1}/{level-2}/{level-3}/{friendly-url}-{id}
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)-([0-9]*)',
+# function($category, $subcategory, $group, $label, $id) {
+# Product::fromID([$$category, $subcategory, $group, $label], $id);
+# }
+# );
+ *
+ * Category
+ * /{category}/[ {subcategory}[ /{group}]]
+# Route::add('/([0-9a-zA-Z-_]*)', function($a) { Category::url([$a]); };
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)', function($a, $b) { Category::url([$a, $b]); });
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)', function($a, $b, $c) { Category::url([$a, $b, $c]); });
+ *
+ * othet routes used in some user-cases
+
+# Route::add('/lesson/([0-9a-f]*)/([0-9a-zA-Z-_]*)',
+# function($ticket, $lesson) { Lesson::show([$lesson, $ticket]); }
+# );
+#
+# Route::add('/media/([0-9a-f]*)/([0-9a-zA-Z-_]*)/([0-9]*)/',
+# function($ticket, $type, $id) { Media::serve([$type, $id, $ticket]); }
+# );
+ *
+ * 4-th level paths belong to products
+# Route::add('/about/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)',
+# function($a, $b, $c) { Page::show([$a, $b, $c]); }
+# );
+ *
+ *
+ * OTHER EXAMPLES (use it for brainsrtorming)
+ * -----------------------------------------------------------------------------
+ *
+ *
+# // Typical Use
+# // ---
+# Route::add('/art', function() { Art::blah(); });
+# Route::add('/art/db', function() { Art::fromDatabase(); });
+# Route::add('/art/([0-9]*)', function($id) { Art::getInfo($id); });
+ *
+ * Get-Post route example
+# Route::add('/contact-form', function() {
+# echo '<form method="post"><input type="text" name="test" /><input type="submit" value="send" /></form>';
+# }, 'get');
+
+ Route::add('/contact-form', function() {
+ echo 'Hey! The form has been sent:<br/>'; print_r($_POST);
+ }, 'post');
+ *
+ * Accept number as parameter
+# Route::add('/foo/([0-9]*)/bar', function($var1) {
+# // echo $var1.' is a number!';
+ echo "{$var1} is a number!";
+# });
+ *
+ * About pages
+# Route::add('/about/([0-9a-zA-Z-_]*)', function($page) {
+# InfoPage::render('about/'.page);
+# });
+ *
+ * handle a request like: /foo/123/bar/3254-lefki-zaxari-marata-1kg
+ * where first-numeric-part of last-uri-section (3254) is the product number
+# Route::add('/foo/([0-9]*)/bar/([0-9]*)-([0-9a-zA-Z-_]*)', function($num, $id, $name) {
+# echo $num .' is some nomber, id = '. $id .' and label = '. $name;
+# });
+ *
+ * handle a request like: /zaxares-glykantika/lefki-zaxari-year-2022-45678
+ * where last-numeric-part of last-uri-section (45678) is the product number
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)-([0-9]*)',
+# function($category, $subcategory, $group, $label, $id) {
+# Shop::product([$$category, $subcategory, $group, $label], $id);
+# });
+ *
+ * last chance to resolve (some friendly url)
+# Route::add('/([0-9a-zA-Z-_]*)', function($a) { Resolve::uri([$a]); });
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)', function($a, $b) { Resolve::uri([$a, $b]); });
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)', function($a, $b, $c) { Resolve::uri([$a, $b, $c]); });
+ *
+ *
+ * Product page
+ * handle a request like: /pantopoleio/zaxares/lefki-zaxari/lefki-zaxari-year-2022-45678
+ * where last-numeric-part of last-uri-section (45678) is the product number
+# Route::add('/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)/([0-9a-zA-Z-_]*)-([0-9]*)',
+# function($category, $subcategory, $group, $label, $id) {
+# Shop::product([$$category, $subcategory, $group, $label], $id);
+# });
+ *
+ * ------------------------------------------------------------------------------
+ */
diff --git a/public/app/routes/user.php b/public/app/routes/user.php
new file mode 100644
index 0000000..7ad0fa6
--- /dev/null
+++ b/public/app/routes/user.php
@@ -0,0 +1,84 @@
+<?php
+
+use app\controllers\Auth;
+use app\controllers\Classroom_user;
+
+/** User Connection Management Routes
+ * -----------------------------------------------------------------------------
+ */
+
+// common requests (GET method)
+// --- -- -- - - -
+
+// request login ... -> then sends to POST:/account/check-login
+Route::add('/login', function() { Render::view('user/login'); });
+
+// request registration ... -> then sends to POST:/account/register
+Route::add('/registration', function() { Render::view('user/registration'); });
+
+// request logout
+Route::add('/logout', function() {
+ Auth::logout();
+ header('Location: /'); die();
+});
+
+// request activation
+Route::add('/account/activate', function() { Auth::activate(); } );
+
+// request password reset
+Route::add('/account/reset_password', function() { Auth::reset_password(); } );
+
+
+
+// Replies to common requests (POST method)
+// --- -- -- - - -
+
+// user sends login form
+Route::add('/account/check-login', function() {
+ $response = Auth::login();
+ Render::json(['status' => $response]);
+ },
+ 'post'
+);
+
+// user sends a registration form
+Route::add('/account/register', function() {
+ $response = Auth::register();
+ Render::json($response);
+ },
+ 'post'
+);
+
+
+// Account Management
+// --- -- -- - - -
+
+// user profile
+Route::add('/account/profile', function() {
+ $user = Auth::is_connected();
+ if ($user === false) {
+ Render::view('error/general',
+ [
+ 'title' => 'Nope!',
+ 'message' => "<h2>No profile</h2>User is not connected"
+ ]
+ );
+
+ } else {
+ Render::json(['success' => true, 'status' => 'user is connected']);
+ }
+ }
+);
+
+
+// temporary (on development)
+// --- -- -- - --
+// NOTE: these is only for testing purposes
+
+// test connection
+if (!PRODUCTION) {
+ Route::add('/check/connection', function() { Auth::is_connected(); });
+}
+
+
+
diff --git a/public/app/views/.gitkeep b/public/app/views/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/views/.gitkeep
diff --git a/public/app/views/admin/admin-menu.php b/public/app/views/admin/admin-menu.php
new file mode 100644
index 0000000..52e8522
--- /dev/null
+++ b/public/app/views/admin/admin-menu.php
@@ -0,0 +1,47 @@
+<?php
+/** admin panel
+ * -----------------------------------------------------------------------------
+ *
+ * imported variables:
+ * ---
+ * @param $action (string): array of articles
+ * @param $id (int): id of entity (when editing entity) | 0: new entity
+ *
+ * example call:
+ * ---
+ * Render::template("sections/admin_panel.php",[
+ * 'action' => 'posts',
+ * 'id' => 4
+ * ])
+ * -----------------------------------------------------------------------------
+ */
+
+$admin_options = [
+ 'panel' => 'Διαχείριση',
+ 'edit_lesson' => 'Νέο Μάθημα',
+ 'lessons' => 'Μαθήματα',
+ 'categories' => 'Κεφάλαια',
+ 'pages' => 'Σελίδες',
+ 'files' => 'Αρχεία',
+ 'privileges' => 'Πρόσβαση',
+ 'users' => 'Χρήστες'
+];
+?>
+
+<div class="top-bar">
+
+ <?php foreach($admin_options as $opt => $label) : ?>
+
+ <?php if (($opt == $action) & (!$id)) : ?>
+ <span class="btn btn-primary selected"><?=$label?></span>
+
+ <?php else : ?>
+ <a href="/admin/<?= $opt ?>" class="btn"><?= $label ?></a>
+
+ <?php endif; ?>
+
+ <?php endforeach ?>
+
+ <a href="/logout" class="btn">Logout</a>
+
+</div> \ No newline at end of file
diff --git a/public/app/views/admin/categories.php b/public/app/views/admin/categories.php
new file mode 100644
index 0000000..26fd5a2
--- /dev/null
+++ b/public/app/views/admin/categories.php
@@ -0,0 +1,91 @@
+ <!-- title bar -->
+ <div class="title-bar">
+ <div><h5>Διαχείριση Κατηγοριών</h5></div>
+ <div>
+ <!-- Button trigger modal -->
+ <button type="button" class="btn pull-right"
+ data-bs-toggle="modal" data-bs-target="#manageCategory" data-id="0">
+ <i class="fas fa-plus"></i> &nbsp; Νέο Κεφάλαιο
+ </button>
+ </div>
+</div>
+
+<div class="manage">
+ <div class="row">
+ <div class="col-md-12">
+
+ <div id="categories-tree">
+
+ <table id="dt-categories" class="table table-responsive dt-table" style="width:100%">
+ <thead>
+ <th class="dt-breadcrumb">Κατηγορία/Κεφάλαιο</th>
+ <th class="dt-actions"></th>
+ </thead>
+ </table>
+
+ </div>
+
+
+ </div>
+ </div>
+</div><!-- /manage -->
+
+
+
+<!-- MODAL for new/edit category -->
+<!-- NOTE: tabindex="-1" removed, ref:https://github.com/select2/select2-bootstrap-theme/issues/41 -->
+<div class="modal fade" id="manageCategory" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+
+ <form method="post" action="">
+
+ <div class="modal-header">
+ <h4 class="modal-title" id="myModalLabel">Modal title</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div><!-- /modal-header -->
+
+ <!-- modal-body -->
+ <div class="modal-body">
+
+ <!-- form fields -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="label">Όνομα κατηγορίας</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="label" value="" required="">
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="parent_id">Γονική Κατηγορία</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" id="parent_id" name="parent_id" required></select>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <input type="hidden" name="id" value="0"><!-- action = (0) ? 'new' : 'edit' -->
+ </div>
+
+
+ </div><!-- /modal body -->
+
+ <div class="modal-footer">
+ <div class="row" style="width: 50%;">
+ <div class="form-actions">
+ <div class="col-4">
+ <button type="button" class="btn btn-default js-modal-close col-sm-12" data-bs-dismiss="modal">Κλείσιμο</button>
+ </div>
+ <div class="col-8">
+ <button class="btn btn-success col-sm-12" type="submit">Καταχώριση</button>
+ </div>
+ </div>
+ </div>
+ </div><!-- /modal-footer -->
+
+ </form><!-- /form -->
+
+ </div>
+ </div>
+</div>
diff --git a/public/app/views/admin/edit_lesson.php b/public/app/views/admin/edit_lesson.php
new file mode 100644
index 0000000..ef875b4
--- /dev/null
+++ b/public/app/views/admin/edit_lesson.php
@@ -0,0 +1,159 @@
+<?php
+ // some labels (if editing new or existing record)
+ // --- -- -- - - -
+ if ($id == 0) {
+ $action_title = "Δημιουργία Μαθήματος";
+ $action_button = "Δημιουργία";
+
+ } else {
+ $action_title = "Επεξεργασία Μαθήματος";
+ $action_button = "Αποθήκευση Αλλαγών";
+ }
+?>
+
+<!-- title bar -->
+<div class="title-bar">
+ <div><h5><?=$action_title?></h5></div>
+ <div>
+ <button type="button" class="btn pull-right"
+ data-bs-toggle="modal" data-bs-target="#editorModal" data-action="preview">
+ <i class="fas fa-search"></i> &nbsp; Προεπισκόπηση
+ </button>
+ </div>
+</div>
+
+<!-- manage -->
+<div class="manage edit-post">
+ <form>
+ <div class="row">
+
+
+ <div class="col col-8">
+
+ <!-- id (hidden) -->
+ <input type="hidden" name="id" value="0"><!-- action = (0) ? 'new' : 'edit' -->
+
+ <!--
+ <div class="row form-group">
+ <input type="date" name="date" value="" readonly="">
+ </div>
+ -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="title">Τίτλος</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="title" value="" required="">
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="category_id">Κεφάλαιο</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm" id="course_id" name="course_id" required></select>
+ </div>
+ </div>
+
+ <!-- Post body -->
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="body">Κείμενο</label>
+ <div class="col-sm-12">
+ <textarea class="form-control form-control-sm" type="text" name="body" value="" required=""></textarea>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="col">
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="status">Κατάσταση</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" name="status" onchange="this.dataset.chosen = this.value;">
+ <option value="0">Αδημοσίευτο</option>
+ <option value="one">ΔΗΜΟΣΙΕΥΜΕΝΟ</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="privilege_id">Επίπεδο Πρόσβασης</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" id="privilege_id" name="privilege_id">
+ </select>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="intro">Σύντομη Περιγραφή</label>
+ <div class="col-sm-12">
+ <textarea class="form-control form-control-sm" type="text" name="intro" value=""></textarea>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="media">Αρχεία</label>
+ <div class="col-sm-12 post-media">
+
+ <!-- Button trigger modal -->
+ <button type="button" class="btn btn-sm btn-success pull-right over-bar"
+ data-bs-toggle="modal" data-bs-target="#editorModal" data-action="upload_file">
+ <i class="fas fa-plus"></i> &nbsp; Προσθήκη
+ </button>
+
+ <ul id="files_list">
+ </ul>
+
+ </div>
+ </div>
+
+ <?php if ($id != 0) : ?>
+ <div class="row form-group">
+ <label class="control-label col-sm-12">Πληροφορίες</label>
+ <div class="col-sm-12" id="post-info">
+ </div>
+ </div>
+ <?php endif; ?>
+
+ </div>
+
+ </div>
+
+ <div class="row">
+
+ <div class="col">
+ <hr/>
+ <!-- close | submit buttons -->
+
+ <div class="form-actions row justify-content-md-center">
+
+ <div class="col-3">
+ <button type="button" id="go-back" class="btn btn-light col-sm-12">
+ Επιστροφή
+ </button>
+ </div>
+
+ <div class="col-4">
+ <button class="btn btn-primary col-sm-12" type="submit">
+ <?=$action_button?>
+ </button>
+ </div>
+
+ </div>
+
+ </div>
+
+ </form>
+
+ </div>
+</div><!-- /manage -->
+
+
+<!-- MODAL for file uploads and preview -->
+<div class="modal fade" id="editorModal" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+ <!-- content will be dynamic -->
+ </div>
+ </div>
+</div>
+
diff --git a/public/app/views/admin/edit_page.php b/public/app/views/admin/edit_page.php
new file mode 100644
index 0000000..46c47bc
--- /dev/null
+++ b/public/app/views/admin/edit_page.php
@@ -0,0 +1,139 @@
+<?php
+ // some labels (if editing new or existing record)
+ // --- -- -- - - -
+ if ($id == 0) {
+ $action_title = "Δημιουργία Σελίδας";
+ $action_button = "Δημιουργία";
+
+ } else {
+ $action_title = "Επεξεργασία Σελίδας";
+ $action_button = "Αποθήκευση Αλλαγών";
+ }
+?>
+
+<!-- title bar -->
+<div class="title-bar">
+ <div><h5><?=$action_title?></h5></div>
+ <div>
+ <button type="button" class="btn pull-right"
+ data-bs-toggle="modal" data-bs-target="#editorModal" data-action="preview">
+ <i class="fas fa-search"></i> &nbsp; Προεπισκόπηση
+ </button>
+ </div>
+</div>
+
+<!-- manage -->
+<div class="manage edit-page">
+ <form>
+ <div class="row">
+
+
+ <div class="col col-8">
+
+ <!-- id (hidden) -->
+ <input type="hidden" name="id" value="0"><!-- action = (0) ? 'new' : 'edit' -->
+
+ <!--
+ <div class="row form-group">
+ <input type="date" name="date" value="" readonly="">
+ </div>
+ -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="title">Τίτλος</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="title" value="" required="">
+ </div>
+ </div>
+
+ <!-- Post body -->
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="body">Κείμενο</label>
+ <div class="col-sm-12">
+ <textarea class="form-control form-control-sm" type="text" name="body" value="" required=""></textarea>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="col">
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="status">Κατάσταση</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" name="status" onchange="this.dataset.chosen = this.value;">
+ <option value="0">Αδημοσίευτο</option>
+ <option value="one">ΔΗΜΟΣΙΕΥΜΕΝΟ</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="media">Αρχεία</label>
+ <div class="col-sm-12 post-media">
+
+ <!-- Button trigger modal -->
+ <button type="button" class="btn btn-sm btn-success pull-right over-bar"
+ data-bs-toggle="modal" data-bs-target="#editorModal" data-action="upload_file">
+ <i class="fas fa-plus"></i> &nbsp; Προσθήκη
+ </button>
+
+ <ul id="files_list">
+ </ul>
+
+ </div>
+ </div>
+
+ <?php if ($id != 0) : ?>
+ <!--
+ <div class="row form-group">
+ <label class="control-label col-sm-12">Πληροφορίες</label>
+ <div class="col-sm-12" id="post-info">
+ </div>
+ </div>
+ -->
+ <?php endif; ?>
+
+ </div>
+
+ </div>
+
+ <div class="row">
+
+ <div class="col">
+ <hr/>
+ <!-- close | submit buttons -->
+
+ <div class="form-actions row justify-content-md-center">
+
+ <div class="col-3">
+ <button type="button" id="go-back" class="btn btn-light col-sm-12">
+ Επιστροφή
+ </button>
+ </div>
+
+ <div class="col-4">
+ <button class="btn btn-primary col-sm-12" type="submit">
+ <?=$action_button?>
+ </button>
+ </div>
+
+ </div>
+
+ </div>
+
+ </form>
+
+ </div>
+</div><!-- /manage -->
+
+
+<!-- MODAL for file uploads and preview -->
+<div class="modal fade" id="editorModal" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+ <!-- content will be dynamic -->
+ </div>
+ </div>
+</div>
+
diff --git a/public/app/views/admin/files.php b/public/app/views/admin/files.php
new file mode 100644
index 0000000..c6d2771
--- /dev/null
+++ b/public/app/views/admin/files.php
@@ -0,0 +1,96 @@
+ <!-- title bar -->
+ <div class="title-bar">
+ <div><h5>Διαχείριση Επιπέδων Πρόσβασης</h5></div>
+ <div>
+ <!-- Button trigger modal
+ <button type="button" class="btn pull-right"
+ data-bs-toggle="modal" data-bs-target="#managePrivileges" data-id="0">
+ <i class="fas fa-plus"></i> &nbsp; Νέο Επίπεδο
+ </button>
+ -->
+ </div>
+</div>
+
+<div class="manage">
+ <div class="row">
+ <div class="col-md-12">
+
+ <div id="files">
+
+ <table id="dt-files" class="table table-responsive dt-table" style="width:100%">
+ <thead>
+ <th class="dt-id">α/α</th>
+ <th class="dt-label">Περιγραφή</th>
+ <th class="dt-path">Διαδρομή</th>
+ <th class="dt-type">Media-Type</th>
+ <th class="dt-privilege">Πρόσβαση</th>
+ <th class="dt-actions"></th>
+ </thead>
+ </table>
+
+ </div>
+
+
+ </div>
+ </div>
+</div><!-- /manage -->
+
+
+
+<!-- MODAL for new/edit category -->
+<!-- NOTE: tabindex="-1" removed, ref:https://github.com/select2/select2-bootstrap-theme/issues/41 -->
+<div class="modal fade" id="manageFiles" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+
+ <form method="post" action="">
+
+ <div class="modal-header">
+ <h4 class="modal-title" id="myModalLabel">Πρόσβαση αρχείου</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div><!-- /modal-header -->
+
+ <!-- modal-body -->
+ <div class="modal-body">
+
+ <!-- form fields -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="label">Κατηγορία Επιπέδου Πρόσβασης</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="label" value="" required="">
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="included_id">Κατηγορία Επιπέδου Πρόσβασης</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" id="privilege_id" name="privilege_id" required></select>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <input type="hidden" name="id" value="0"><!-- action = (0) ? 'new' : 'edit' -->
+ </div>
+
+
+ </div><!-- /modal body -->
+
+ <div class="modal-footer">
+ <div class="row form-actions">
+
+ <div class="col">
+ <button type="button" class="btn btn-default js-modal-close col-sm-12" data-bs-dismiss="modal">Κλείσιμο</button>
+ </div>
+ <div class="col">
+ <button class="btn btn-success col-sm-12" type="submit">Καταχώριση</button>
+ </div>
+
+ </div>
+ </div><!-- /modal-footer -->
+
+ </form><!-- /form -->
+
+ </div>
+ </div>
+</div>
diff --git a/public/app/views/admin/lessons.php b/public/app/views/admin/lessons.php
new file mode 100644
index 0000000..d1cd3ae
--- /dev/null
+++ b/public/app/views/admin/lessons.php
@@ -0,0 +1,126 @@
+ <!-- title bar -->
+ <div class="title-bar">
+ <div><h5>Διαχείριση Μαθημάτων</h5></div>
+ <div>
+ <!-- Button trigger modal -->
+ <a type="button" class="btn pull-right" href="/admin/edit_lesson">
+ <i class="fas fa-plus"></i> &nbsp; Νέο Μάθημα
+ </a>
+ </div>
+</div>
+
+<div class="manage">
+ <div class="row">
+ <div class="col-md-12">
+
+ <div id="lessons">
+
+ <table id="dt-lessons" class="table table-responsive dt-table" style="width:100%">
+ <thead>
+ <th class="dt-id">α/α</th>
+ <th class="dt-title">Τίτλος</th>
+ <th class="dt-course">Κεφάλαιο</th>
+ <th class="dt-privileges">Πρόσβαση</th>
+ <th class="dt-actions"></th>
+ </thead>
+ </table>
+
+ </div>
+
+ </div>
+ </div>
+</div><!-- /manage -->
+
+
+
+<!-- MODAL for new/edit category -->
+<!-- NOTE: tabindex="-1" removed, ref:https://github.com/select2/select2-bootstrap-theme/issues/41 -->
+<div class="modal fade" id="manageLessons" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+
+ <form method="post" action="">
+
+ <div class="modal-header">
+ <h4 class="modal-title" id="myModalLabel">Παράμετροι Μαθήματος</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div><!-- /modal-header -->
+
+ <!-- modal-body -->
+ <div class="modal-body">
+
+ <!-- form fields -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="label">Τίτλος</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="title" value="" required="">
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="course_id">Κεφάλαιο</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" id="course_id" name="course_id" required></select>
+ </div>
+ </div>
+
+ <!-- one line, two fields (privilege, status) -->
+ <div class="row">
+ <div class="col-md-8">
+
+ <!-- privilege level -->
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="privilege_id">Επίπεδο Πρόσβασης</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" id="privilege_id" name="privilege_id">
+ </select>
+ </div>
+ </div>
+
+ </div>
+ <div class="col-md-4">
+
+ <!-- status: published|unpublished -->
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="status">Κατάσταση</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" name="status" onchange="this.dataset.chosen = this.value;">
+ <option value="0">Αδημοσίευτο</option>
+ <option value="one">ΔΗΜΟΣΙΕΥΜΕΝΟ</option>
+ </select>
+ </div>
+ </div>
+
+
+ </div>
+ </div>
+
+
+
+
+ <div class="row form-group">
+ <input type="hidden" name="id" value="0"><!-- lesson-id -->
+ </div>
+
+
+ </div><!-- /modal body -->
+
+ <div class="modal-footer">
+ <div class="row" style="width: 50%;">
+ <div class="form-actions">
+ <div class="col-4">
+ <button type="button" class="btn btn-default js-modal-close col-sm-12" data-bs-dismiss="modal">Κλείσιμο</button>
+ </div>
+ <div class="col-8">
+ <button class="btn btn-success col-sm-12" type="submit">Καταχώριση</button>
+ </div>
+ </div>
+ </div>
+ </div><!-- /modal-footer -->
+
+ </form><!-- /form -->
+
+ </div>
+ </div>
+</div>
diff --git a/public/app/views/admin/pages.php b/public/app/views/admin/pages.php
new file mode 100644
index 0000000..dd8f954
--- /dev/null
+++ b/public/app/views/admin/pages.php
@@ -0,0 +1,109 @@
+ <!-- title bar -->
+ <div class="title-bar">
+ <div><h5>Διαχείριση Μαθημάτων</h5></div>
+ <div>
+ <!-- Button trigger modal -->
+ <a type="button" class="btn pull-right" href="/admin/edit_page">
+ <i class="fas fa-plus"></i> &nbsp; Νέα Σελίδα
+ </a>
+ </div>
+</div>
+
+<div class="manage">
+ <div class="row">
+ <div class="col-md-12">
+
+ <div id="pages">
+
+ <table id="dt-pages" class="table table-responsive dt-table" style="width:100%">
+ <thead>
+ <th class="dt-id">α/α</th>
+ <th class="dt-title">Τίτλος</th>
+ <th class="dt-actions"></th>
+ </thead>
+ </table>
+
+ </div>
+
+ </div>
+ </div>
+</div><!-- /manage -->
+
+
+
+<!-- MODAL for new/edit category -->
+<!-- NOTE: tabindex="-1" removed, ref:https://github.com/select2/select2-bootstrap-theme/issues/41 -->
+<div class="modal fade" id="managePages" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+
+ <form method="post" action="">
+
+ <div class="modal-header">
+ <h4 class="modal-title" id="myModalLabel">Παράμετροι Μαθήματος</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div><!-- /modal-header -->
+
+ <!-- modal-body -->
+ <div class="modal-body">
+
+ <!-- form fields -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="label">Τίτλος</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="title" value="" required="">
+ </div>
+ </div>
+
+
+ <!-- one line / two parts; status: pull right -->
+ <div class="row">
+ <div class="col-md-8">
+ &nbsp<!-- nothing -->
+ </div>
+ <div class="col-md-4">
+
+ <!-- status: published|unpublished -->
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="status">Κατάσταση</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" name="status" onchange="this.dataset.chosen = this.value;">
+ <option value="0">Αδημοσίευτο</option>
+ <option value="one">ΔΗΜΟΣΙΕΥΜΕΝΟ</option>
+ </select>
+ </div>
+ </div>
+
+
+ </div>
+ </div>
+
+
+
+
+ <div class="row form-group">
+ <input type="hidden" name="id" value="0"><!-- page-id -->
+ </div>
+
+
+ </div><!-- /modal body -->
+
+ <div class="modal-footer">
+ <div class="row" style="width: 50%;">
+ <div class="form-actions">
+ <div class="col-4">
+ <button type="button" class="btn btn-default js-modal-close col-sm-12" data-bs-dismiss="modal">Κλείσιμο</button>
+ </div>
+ <div class="col-8">
+ <button class="btn btn-success col-sm-12" type="submit">Καταχώριση</button>
+ </div>
+ </div>
+ </div>
+ </div><!-- /modal-footer -->
+
+ </form><!-- /form -->
+
+ </div>
+ </div>
+</div>
diff --git a/public/app/views/admin/panel.php b/public/app/views/admin/panel.php
new file mode 100644
index 0000000..59b8cba
--- /dev/null
+++ b/public/app/views/admin/panel.php
@@ -0,0 +1,4 @@
+<br/>
+<br/>
+<br/>
+<h4>Admin panel</h4> \ No newline at end of file
diff --git a/public/app/views/admin/privileges.php b/public/app/views/admin/privileges.php
new file mode 100644
index 0000000..587c363
--- /dev/null
+++ b/public/app/views/admin/privileges.php
@@ -0,0 +1,93 @@
+ <!-- title bar -->
+ <div class="title-bar">
+ <div><h5>Διαχείριση Επιπέδων Πρόσβασης</h5></div>
+ <div>
+ <!-- Button trigger modal -->
+ <button type="button" class="btn pull-right"
+ data-bs-toggle="modal" data-bs-target="#managePrivileges" data-id="0">
+ <i class="fas fa-plus"></i> &nbsp; Νέο Επίπεδο
+ </button>
+ </div>
+</div>
+
+<div class="manage">
+ <div class="row">
+ <div class="col-md-12">
+
+ <div id="categories-tree">
+
+ <table id="dt-privileges" class="table table-responsive dt-table" style="width:100%">
+ <thead>
+ <th class="dt-id">Κωδικός</th>
+ <th class="dt-privilege">Επίπεδο Πρόσβασης</th>
+ <th class="dt-includes">Περιλαμβάνει</th>
+ <th class="dt-actions"></th>
+ </thead>
+ </table>
+
+ </div>
+
+
+ </div>
+ </div>
+</div><!-- /manage -->
+
+
+
+<!-- MODAL for new/edit category -->
+<!-- NOTE: tabindex="-1" removed, ref:https://github.com/select2/select2-bootstrap-theme/issues/41 -->
+<div class="modal fade" id="managePrivileges" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg" role="document">
+ <div class="modal-content">
+
+ <form method="post" action="">
+
+ <div class="modal-header">
+ <h4 class="modal-title" id="myModalLabel">Modal title</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div><!-- /modal-header -->
+
+ <!-- modal-body -->
+ <div class="modal-body">
+
+ <!-- form fields -->
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="label">Όνομα Επιπέδου Πρόσβασης</label>
+ <div class="col-sm-12">
+ <input class="form-control form-control-sm" type="text" name="label" value="" required="">
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <label class="control-label col-sm-12" for="included_id">Περιλαμβάνει μέχρι και το Επίπεδο</label>
+ <div class="col-sm-12">
+ <select class="form-control form-control-sm form-select form-select-sm" id="included_id" name="included_id" required></select>
+ </div>
+ </div>
+
+ <div class="row form-group">
+ <input type="hidden" name="id" value="0"><!-- action = (0) ? 'new' : 'edit' -->
+ </div>
+
+
+ </div><!-- /modal body -->
+
+ <div class="modal-footer">
+ <div class="row form-actions">
+
+ <div class="col">
+ <button type="button" class="btn btn-default js-modal-close col-sm-12" data-bs-dismiss="modal">Κλείσιμο</button>
+ </div>
+ <div class="col">
+ <button class="btn btn-success col-sm-12" type="submit">Καταχώριση</button>
+ </div>
+
+ </div>
+ </div><!-- /modal-footer -->
+
+ </form><!-- /form -->
+
+ </div>
+ </div>
+</div>
diff --git a/public/app/views/admin/skeleton.php b/public/app/views/admin/skeleton.php
new file mode 100644
index 0000000..927bd2d
--- /dev/null
+++ b/public/app/views/admin/skeleton.php
@@ -0,0 +1,45 @@
+<?php ob_start(); ?><!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Classroom - <?=$title?></title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes', ////////////////////////////
+ ['administration' => true]
+ );
+ ?>
+
+
+</head>
+<body>
+
+ <div class="classroom admin-container">
+
+ <?php
+ // sometimes an ID (int) is needed
+ // ex. editing a page or a lesson we need page-ID or lesson-ID
+ // --- -- -- - - -
+ if (!isset($id)) $id=0;
+
+ // render menu
+ ////////////////////////////////////////////////////////////////////
+ Render::view('admin/admin-menu',[ ////////////////////////////
+ 'action' => $action,
+ 'id' => $id
+ ]);
+
+ // render main content
+ ////////////////////////////////////////////////////////////////////
+ Render::view("admin/{$action}",[ 'id' => $id ]); ////////////////
+ ?>
+ </div>
+
+ <!-- js admin script -->
+ <script>var entity_id = <?= $id ?>;</script>
+ <script src="/assets/js/admin/<?= $action ?>.js"></script>
+
+</body>
+</html><?php ob_flush(); ?>
diff --git a/public/app/views/components/breadcrumbs.php b/public/app/views/components/breadcrumbs.php
new file mode 100644
index 0000000..59c0fca
--- /dev/null
+++ b/public/app/views/components/breadcrumbs.php
@@ -0,0 +1,30 @@
+<?php
+/** breadcrumbs
+ * -----------------------------------------------------------------------------
+ *
+ * imported variables
+ * ---
+ * @param $id : category_id of last level
+ * @param $label : category title of last level
+ * @param $parents : array of parent nodes
+ * -----------------------------------------------------------------------------
+ */
+
+?>
+<div class="breadcrumb">
+ <!-- home -->
+ <a href="/">Classroom</a>
+
+ <!-- parent nodes -->
+ <?php if ($id != 0) : ?>
+ <?php foreach($parents as $node) : ?>
+ <i class="fas fa-angle-right"></i> <a href="/course/<?=$node['id']?>"><?=$node['label']?></a>
+ <?php endforeach; ?>
+ <?php endif; ?>
+
+ <!-- last (current) node -->
+ <?php if ($id != 0) : ?>
+ <i class="fas fa-angle-right"></i> <a href="/course/<?=$id?>"><?=$label?></a>
+ <?php endif; ?>
+
+</div> \ No newline at end of file
diff --git a/public/app/views/components/courses_menu.php b/public/app/views/components/courses_menu.php
new file mode 100644
index 0000000..6539f8c
--- /dev/null
+++ b/public/app/views/components/courses_menu.php
@@ -0,0 +1,97 @@
+<?php
+/** Categories Menu
+ * -----------------------------------------------------------------------------
+ * sits in the (left) sidebar
+ *
+ * imported variables
+ * ---
+ * @param $categories (array) : the categories tree
+ * @param $open_path (array) : 'id's array of selected categories
+ *
+ * example call:
+ * (draw the categories-menu and set open the categories with ids 1 and 2)
+ * ---
+ * Render::template("section/categories_menu.php",[
+ * 'categories' => $category_tree,
+ * 'open_path' => [1 ,2]
+ * ]);
+ *
+ * -----------------------------------------------------------------------------
+ */
+
+/** tree_ul
+ * creates (recursively) a nested ul-li tree of categories
+ * --- -- -- - - -
+ * @param $tree (array) : categories array formated as tree
+ * @param $open_nodes (array) : array of categor ids that shall be marked open/selected
+ * @return $html (string)
+ */
+function tree_li($tree, $open_nodes = [], $l = 0)
+{
+ $html = "";
+
+ foreach($tree as $node) {
+
+ // if has childs ...
+ // write li ; traverse children recursively
+ if ((isset($node['childs'])) && ( $node['childs']!= [])) {
+
+ if (in_array(intval($node['rec']['id']), $open_nodes)) {
+ $inner_class = "inner show";
+ $sign = "–";
+ $a_class = "has-childs selected";
+
+ } else {
+ $inner_class = "inner";
+ $sign = "+";
+ $a_class = "has-childs";
+ }
+
+ $html .= "<li>
+ <a href='/course/{$node['rec']['id']}' class='{$a_class}'>
+ <span class='label'>{$node['rec']['label']}</span>
+ <span class='toggler'>{$sign}</span>
+ </a>
+ <ul class='{$inner_class}'>"
+ . tree_li($node['childs'], $open_nodes, $l+1)
+ ."</ul>
+ </li>";
+
+
+ } else { // else just write the li
+
+ if (in_array(intval($node['rec']['id']), $open_nodes)) {
+ $u_in = "<u>";
+ $u_out = "</u>";
+ $a_class = "selected";
+
+ } else {
+ $u_in = "";
+ $u_out = "";
+ $a_class = "";
+
+ }
+
+ $html .= "<li>
+ <a href='/course/{$node['rec']['id']}' class='{$a_class}'>
+ <span class='label'>{$node['rec']['label']}</span>
+ </a>
+ </li>";
+ }
+ }
+
+ return $html;
+
+}
+
+
+// draw the menu (hidden on XS screens)
+// -----------------------------------------------------------------------------
+echo "<h3>Διαθέσιμη ύλη</h3>";
+echo "<ul class='hidden-xs side-bar'>". tree_li($categories, $open_path) ."</ul>";
+
+
+
+// TODO:
+// draw menu for XS screens (for example, just the 1st-level categories)
+// (maybe in an accordion so it won;t take too much area on small screens)
diff --git a/public/app/views/components/footer.php b/public/app/views/components/footer.php
new file mode 100644
index 0000000..7cd4a5a
--- /dev/null
+++ b/public/app/views/components/footer.php
@@ -0,0 +1,240 @@
+<?php
+
+use app\extends\Cache_service;
+
+
+/** DESIGNERS
+ * -----------------------------------------------------------------------------
+ *
+ * structures that combine several design information
+ * in a json structrure and parses inta a quite complicated view;
+ *
+ * co-operate with pages
+ *
+ * can be used to rended menus, sections like header or footer etc;
+ *
+ * TODO:
+ * + embed it as a core \Render method
+ * + dynamic designers
+ * + recursive designers
+ *
+ * Here we implement a simplified version of the consept
+ * where entities shall always be `pages`
+ *
+ *
+ * PARAMETRES
+ * --- -- -- - - -
+ *
+ * primary properties of designer structure are 2 properties
+ *
+ * @var class (string): container class name
+ * @var struct (json array): representatoion of the "to-be-designed" structure
+ *
+ * TODO: support more complx `class` strings ex. `.container>#footer>row`
+ *
+ *
+ * Each sub-structure has 2-5 properties:
+ *
+ * @var title (string) : title of the sub-section
+ * @var entity (string) : type of entity (ex: page/lesson/etc; TODO: method of CMS_model?)
+ * @var list (array) : list of entity id(s)
+ * @var template (string) : template that is used to draw the listed pages' info
+ * @var width (string) : class that relates to sub-structure's width
+ *
+ * NOTE:
+ * designer parsing and rendering can be a quite expensive proccess
+ * thus it is strongly recommented to use cached entities
+ *
+ *
+ * Example structure:
+ {
+ "class" : "row", # container class
+ "struct" : [
+ { # block 1
+ "title" : "",
+ "entity" : "page",
+ "list" : [2, 1, 3], # pages to be listed
+ "template" : "list", # list page titles with link to the pages
+ "width" : "col-md-4"
+ },
+ {
+ "template" : "null", # list nothing
+ "width" : "col-md-3"
+ },
+ {
+ "entity" : "page",
+ "list" : [4], # list content of page 4;
+ "template" : "content", # set title to page-title
+ "width" : "col-md-5"
+ }
+
+ TODO: (wish)
+ ,{
+ "template": "struct",
+ "struct" : [
+ {
+ title, entity, list, template, width
+ },
+ ...
+ ]
+ }
+ ]
+ }
+ *
+ * brainstormin: (TODO:)
+ * use some document-describe stucture like the one on the pdfmake js-library
+ * -----------------------------------------------------------------------------
+ */
+
+
+/** check imported variables / data
+ * -----------------------------------------------------------------------------
+ */
+if (!isset($designer)) {
+
+ // default designer (and first case-study)
+ $designer = '{
+ "class" : "row",
+ "struct" : [
+ {
+ "title" : "Πληροφορίες",
+ "entity" : "page",
+ "list" : [2, 1, 3, 4],
+ "template" : "list",
+ "width" : "col-md-4 no-list-mark"
+ },
+ {
+ "entity" : "page",
+ "list" : [6],
+ "template" : "content",
+ "width" : "col-md-3 no-list-mark"
+ },
+ {
+ "entity" : "page",
+ "list" : [5],
+ "template" : "content",
+ "width" : "col-md-5 no-list-mark"
+ }
+ ]
+ }';
+}
+
+// $entity list shall be passed
+// entity link url shall be passed
+
+
+/** templating functions
+ * -----------------------------------------------------------------------------
+ */
+
+/** List template
+ * --- -- -- - - -
+ *
+ * lists entity titles with links
+ * in a `ul>li` html-structure
+ *
+ * @param $title
+ * @param $list
+ * @param $container
+ */
+function list_template($title, $list, $container, $entity) {
+
+ $result = '<div class="'. $container .'">
+ <h4>'. $title .'</h4>
+ <ul>';
+
+ foreach($list as $id) {
+
+ if ($entity[$id]['status'] == 1) { // if entity is published
+ $result .= '
+ <li>
+ <a href="/page/'. $id . '">
+ ' . $entity[$id]['title'] .'
+ </a>
+ </li>';
+ }
+
+ }
+ return $result . '</ul></div>';
+}
+
+/** content_template
+ *
+ * lists whole title and content of entity
+ *
+ * @param $list
+ * @param $container
+ */
+function content_template($list, $container, $entity) {
+
+ $parsedown = new Parsedown();
+
+ $result = '<div class="'. $container .'"><ul>';
+
+ foreach($list as $id) {
+
+ if ($entity[$id]['status'] == 1) { // if entity is published
+
+ $result .= '
+ <li>
+ <h4>'. $entity[$id]['title'] .'</h4>
+ <div>'. $parsedown->text($entity[$id]['body']) .'</div>
+ </li>';
+ }
+
+ }
+ return $result .'</ul></div>';
+}
+
+
+/** ready to start the proccessing
+ * -----------------------------------------------------------------------------
+ */
+
+// decode ...
+$parse = json_decode($designer);
+
+/** then start parse-proccessing ...
+ * -----------------------------------------------------------------------------
+ */
+?>
+
+
+
+<div class="<?=$parse->class?>">
+
+ <?php
+ foreach( $parse->struct as $section ) {
+
+ switch ($section->template) {
+
+ case 'list':
+ echo list_template(
+ $section->title,
+ $section->list,
+ $section->width,
+ $entity
+ );
+ break;
+
+ case 'content':
+ echo content_template(
+ $section->list,
+ $section->width,
+ $entity
+ );
+ break;
+
+ default: // ex. 'null'
+
+ echo '<div class="'. $section->width .'">
+ <h4>'. $section->title.'</h4>
+ </div>';
+ }
+
+ }
+ ?>
+
+</div>
+
+
diff --git a/public/app/views/components/header_includes.php b/public/app/views/components/header_includes.php
new file mode 100644
index 0000000..62dc616
--- /dev/null
+++ b/public/app/views/components/header_includes.php
@@ -0,0 +1,59 @@
+<?php
+ if (!isset($administration)) {
+ $administration = false;
+ }
+?>
+
+<!-- fonts (CDN) -->
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@400&display=swap" rel="stylesheet">
+
+<!-- fontawesome icons (CDN) -->
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
+
+<!-- bootstap (CDN) -->
+<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-aFq/bzH65dt+w6FI2ooMVUpc+21e0SRygnTpmBvdBgSdnuTN7QbdgL+OapgHtvPp" crossorigin="anonymous">
+
+<?php if ($administration) : ?>
+ <!-- datatables css -->
+ <link href="https://cdn.datatables.net/v/bs5/jszip-2.5.0/dt-1.13.4/b-2.3.6/b-colvis-2.3.6/b-html5-2.3.6/b-print-2.3.6/cr-1.6.2/date-1.4.0/fc-4.2.2/fh-3.3.2/kt-2.8.2/r-2.4.1/rr-1.3.3/sc-2.1.1/sl-1.6.2/sr-1.2.2/datatables.min.css" rel="stylesheet"/>
+
+ <!-- select2 css -->
+ <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
+<?php endif ?>
+
+
+<!-- main css -->
+<link href="/assets/css/class.css" rel="stylesheet">
+
+<!-- overides -->
+<link href="/assets/css/overides.css" rel="stylesheet">
+
+
+
+<!-- bootstrap javascript (CDN) -->
+<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/js/bootstrap.bundle.min.js" integrity="sha384-qKXV1j0HvMUeCBQ+QVp7JcfGl760yU08IQ+GpUo5hlbpg51QRiuqHAJz8+BrxE/N" crossorigin="anonymous"></script>
+
+<!-- jQuery (CDN) -->
+<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js" integrity="sha512-pumBsjNRGGqkPzKHndZMaAG+bir374sORyzM3uulLV14lN5LyykqNk8eEeUlUkB3U0M4FApyaHraT65ihJhDpQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+
+<!-- basic cookie handling (vanilla js) -->
+<script src="/assets/js/cookie.js"></script>
+
+
+<?php if ($administration) : ?>
+ <!-- datatables js -->
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
+ <script src="https://cdn.datatables.net/v/bs5/jszip-2.5.0/dt-1.13.4/b-2.3.6/b-colvis-2.3.6/b-html5-2.3.6/b-print-2.3.6/cr-1.6.2/date-1.4.0/fc-4.2.2/fh-3.3.2/kt-2.8.2/r-2.4.1/rr-1.3.3/sc-2.1.1/sl-1.6.2/sr-1.2.2/datatables.min.js"></script>
+
+ <!-- select2 js -->
+ <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
+
+ <!-- marked -->
+ <script src="/assets/js/marked/marked.min.js"></script>
+<?php endif ?>
+
+
+
diff --git a/public/app/views/components/lessons_list.php b/public/app/views/components/lessons_list.php
new file mode 100644
index 0000000..a778d59
--- /dev/null
+++ b/public/app/views/components/lessons_list.php
@@ -0,0 +1,54 @@
+<?php
+/** lessons list
+ * -----------------------------------------------------------------------------
+ *
+ * imported variables:
+ * ---
+ * @param $lessons : array of lessons
+ * @param $fieldset : array of optional fields to show [category, intro, date]
+ *
+ * example call:
+ * ---
+ * Render::template("sections/articles_list.php",[
+ * 'articles' => $category_tree,
+ * 'fieldset' => ['date', 'intro']
+ * ])
+ * -----------------------------------------------------------------------------
+ */
+?>
+
+
+<?php foreach($lessons as $post) : ?>
+
+ <!-- <div class="col-xl-4 col-lg-6"> -->
+ <div class="post-card">
+
+ <?php if (in_array('date', $fieldset)) : ?>
+ <?=$post['date']?>
+ <?php endif; ?>
+
+ <div class="post-card--content">
+
+ <h3>
+ <a href="/lesson/<?=$post['id']?>"><?=$post['title']?></a>
+ </h3>
+
+ <?php if (in_array('level', $fieldset)) : ?>
+ <p class="getegory">(επίπεδο πρόσβασης <?=$post['privilege_id']?>)</p>
+ <?php endif; ?>
+
+ <?php if (in_array('category', $fieldset)) : ?>
+ <p class="category"><?=$post['course_id']?></p>
+ <?php endif; ?>
+
+ <?php if (in_array('intro', $fieldset) && $post['intro']) : ?>
+ <p class="intro"><?=$post['intro']?></p>
+ <?php endif; ?>
+
+ </div>
+
+ </div>
+ <!-- </div> -->
+
+<?php endforeach; ?>
+
diff --git a/public/app/views/components/subcategories.php b/public/app/views/components/subcategories.php
new file mode 100644
index 0000000..5c05a94
--- /dev/null
+++ b/public/app/views/components/subcategories.php
@@ -0,0 +1,19 @@
+<!-- Sub-Categories -->
+
+<?php if ($breadcrumbs[ $id ]['childs'] != []) : ?>
+
+ <h5>Υποκεφάλαια</h5>
+
+ <div class="category-childs">
+
+ <?php foreach($breadcrumbs[ $id ]['childs'] as $key => $child) : ?>
+
+ <a href="/course/<?=$child['id']?>" class="btn btn-light">
+ <?=$child['label']?>
+ </a>
+
+ <?php endforeach; ?>
+
+ </div>
+
+<?php endif; ?> \ No newline at end of file
diff --git a/public/app/views/components/top_bar.php b/public/app/views/components/top_bar.php
new file mode 100644
index 0000000..eaffe76
--- /dev/null
+++ b/public/app/views/components/top_bar.php
@@ -0,0 +1,40 @@
+<?php
+/** top bar
+ * -----------------------------------------------------------------------------
+ *
+ * rendrers breadcrumbs and user menu
+ *
+ * imported variables
+ * ---
+ * @param $id : category_id of last level
+ * @param $label : category title of last level
+ * @param $parents : array of parent nodes
+ * -----------------------------------------------------------------------------
+ */
+
+// initialize variables needed if not passed
+// (needed while requiring breadcrumbs)
+if (!isset($id)) {
+ $id = 0;
+ $label = "";
+ $parents = [];
+}
+
+?>
+<div class="top-bar">
+
+ <?php // breadcrumbs
+ ////////////////////////////////////////////////////////////////////////
+ Render::view("components/breadcrumbs", [
+ 'id' => $id,
+ 'label' => $label,
+ 'parents' => $parents
+ ]);
+ ?>
+
+ <?php // User management drop-down
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/user-management');
+ ?>
+
+</div><!-- /top-bar --> \ No newline at end of file
diff --git a/public/app/views/components/user-management.php b/public/app/views/components/user-management.php
new file mode 100644
index 0000000..636f2ff
--- /dev/null
+++ b/public/app/views/components/user-management.php
@@ -0,0 +1,65 @@
+<div class="user">
+
+ <div class="dropdown js-visitor">
+ <button type="button" class="btn btn-default dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+ <i class="fa fa-regular fa-user"></i>
+ Διαχείριση Λογαριασμού
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="/login">Είσοδος</a></li>
+
+ <li><hr class="dropdown-divider"></li>
+
+ <li><a class="dropdown-item" href="/registration">Εγγραφή</a></li>
+ <li><a class="dropdown-item disabled" href="/reset-password">Επαναφορά password</a></li>
+ </ul>
+ </div>
+
+ <div class="dropdown js-subscriber">
+ <button type="button" class="btn btn-default dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+ <i class="fa fa-regular fa-user"></i>
+ Διαχείριση Λογαριασμού <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li><a class="dropdown-item" href="/account/profile">Διαχείριση Προφίλ</a></li>
+ <li><a class="dropdown-item" href="/account/courses">Ύλη μαθημάτων</a></li>
+ <li><a class="dropdown-item" href="/account/payments">Πληρωμές</a></li>
+
+ <li><hr class="dropdown-divider"></li>
+
+ <li><a class="dropdown-item" href="/logout">Έξοδος</a></li>
+ </ul>
+ </div>
+
+</div>
+
+
+<script>
+ // if user is connected hide anonymous user menu and show connected user's content
+ if (cookieExists('cluser') && (readCookie('cluser').includes('connected'))) {
+ [].forEach.call(document.querySelectorAll('.js-visitor'), function (el) {
+ el.style.display = 'none';
+ });
+ [].forEach.call(document.querySelectorAll('.js-subscriber'), function (el) {
+ el.style.display = 'unset';
+ });
+
+ // also extract user's full name to personalize messages
+ let connection = unescape(readCookie('cluser')).split(';');
+ connection.shift(); // remove 1st item (="connection")
+ let name = connection.join(' '); console.log(name);
+ var node = document.querySelectorAll('.js-subscriber button')[0];
+ node.innerHTML = `<i class="fa fa-regular fa-user"></i> ${name} <span class="caret"></span>`;
+
+
+ } else { // else ... do the opposite
+ [].forEach.call(document.querySelectorAll('.js-subscriber'), function (el) {
+ el.style.display = 'none';
+ });
+ [].forEach.call(document.querySelectorAll('.js-visitor'), function (el) {
+ el.style.display = 'unset';
+ });
+ }
+
+</script>
diff --git a/public/app/views/error/404.php b/public/app/views/error/404.php
new file mode 100644
index 0000000..a085830
--- /dev/null
+++ b/public/app/views/error/404.php
@@ -0,0 +1,65 @@
+<?php
+/** general error page
+ *
+ * defaults to 404 error but can be used for any other error code
+ *
+ * imported variables:
+ *
+ * optional @var $error_code (int) : error code
+ * optional @var $moto (string) : super-mini description text
+ * optional @var $message (string)
+ *
+ */
+
+ if (!isset($error_code)) { // if error code not set
+ $error_code = 404; // set default (404)
+ }
+
+ http_response_code(403);
+
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>404: Not Found</title>
+
+ <style>
+ html,body{
+ padding:0; margin:0;
+ font-size:1.25em; font-family: Arial, Helvetica, Ubuntu, sans-serif;
+ color: #037; background: #eee;
+ line-height:1.2em;
+ }
+ h3 {
+ font-size: 2em; font-style: normal;
+ font-weight: 400;
+ color: #05a;
+ margin: 0; padding: 5px;
+ opacity: 0.27;
+ border-bottom: 1px solid #05a;
+ }
+ .container { display: flex; height: 100vh; align-items:center; }
+ .content { position: relative;
+ max-width:320px; width: 100%;
+ margin: auto auto; padding: 1.5em 1.5em 3em 1.5em;
+ text-align: center; justify-center: center;
+ opacity: .33;
+ }
+ .btn { font-size: 18px; padding: 4px 12px; }
+ </style>
+ </head>
+ <body>
+ <div class='container'>
+ <div class='content'>
+ <h3><?=$error_code?></h3>
+ <?= $moto ?? 'Not found' ?><br />
+ <!--
+ —
+ <br />
+ <span><?= $message ?? "and I still haven't found what I'm looking for." ?></span>
+ -->
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/public/app/views/error/general.php b/public/app/views/error/general.php
new file mode 100644
index 0000000..6ed984b
--- /dev/null
+++ b/public/app/views/error/general.php
@@ -0,0 +1,64 @@
+<?php
+/** general message template
+ * -----------------------------------------------------------------------------
+ * renders an error with custom title and message
+ *
+ * passed values:
+ *
+ * optional @var $title (string): title
+ * optional @var $message (string): message
+ *
+ * NOTE:
+ * if title and/or message are not passed, then defaults are used
+ *
+ * -----------------------------------------------------------------------------
+ */
+
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>
+ <?= isset($title)
+ ? (SITE_TITLE .": ". $title)
+ : PAGE_404_TITLE
+ ?>
+ </title>
+
+ <style>
+ html,body{
+ padding:0; margin:0;
+ font-size:1.25em; font-family: Arial, Helvetica, Ubuntu, sans-serif;
+ color: #037; background: #eee;
+ line-height:1.2em;
+ }
+ h3 {
+ font-size: 2em; font-style: normal;
+ font-weight: 400;
+ color: #05a;
+ margin: 0; padding: 5px;
+ opacity: 0.27;
+ border-bottom: 1px solid #05a;
+ }
+ .container { display: flex; height: 100vh; align-items:center; }
+ .content { position: relative;
+ max-width: 480px; width: 100%;
+ margin: auto auto; padding: 1.5em 1.5em 3em 1.5em;
+ text-align: center; justify-center: center;
+ opacity: .33;
+ font-size: .87em
+ }
+ .btn { font-size: 18px; padding: 4px 12px; }
+ </style>
+ </head>
+ <body>
+ <div class='container'>
+ <div class='content'>
+ <h3><?= isset($title) ? $title : PAGE_404_TITLE ?></h3>
+ <br />
+ <br />
+ <span><?= $message ?? "" ?></span>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/public/app/views/group.php b/public/app/views/group.php
new file mode 100644
index 0000000..5d5dea0
--- /dev/null
+++ b/public/app/views/group.php
@@ -0,0 +1,13 @@
+<section>
+ <div style="padding: 1em">
+
+ <?php foreach ($group as $item) : ?>
+
+ <div style="padding: 1em">
+ <?php render_view('item', ['item' => $item]) ?>
+ </div>
+
+ <?php endforeach; ?>
+
+ </div>
+</section>
diff --git a/public/app/views/item.php b/public/app/views/item.php
new file mode 100644
index 0000000..2354c81
--- /dev/null
+++ b/public/app/views/item.php
@@ -0,0 +1,4 @@
+<h3><?=$item['title']?></h3>
+<p>
+ <i><?=$item['info']?></i>
+</p> \ No newline at end of file
diff --git a/public/app/views/simple/Model.php b/public/app/views/simple/Model.php
new file mode 100644
index 0000000..6ea1e7d
--- /dev/null
+++ b/public/app/views/simple/Model.php
@@ -0,0 +1,887 @@
+<?php
+/** Wiki Model Class
+ * -----------------------------------------------------------------------------
+ *
+ * + connects to the Wiki database; forwards SQL queries and returns data
+ *
+ * + forms data to be used in the application
+ *
+ * + implements some optimizations (data-caching)
+ *
+ * -----------------------------------------------------------------------------
+ */
+class Wiki_Model
+{
+
+ protected $pdo;
+
+ protected $pdo_pythia;
+
+ protected $user_id; // connected user-id
+
+ protected $wiki_files_root = 'uploads/wiki/files'; // root path for wiki file attachments
+
+
+ /** __construct
+ *
+ * Connects to the Wiki database
+ *
+ */
+ public function __construct()
+ {
+ /// // Default
+ /// $db = new Database();
+ /// $this->pdo_pythia= $db->connect();
+
+ /// // database for wiki
+ /// $this->pdo = new PDO('mysql:unix_socket=/cloudsql/pythia-251711:europe-west4:pythia-db-eu;dbname=' . DB_DATABASE . ';charset=utf8', DB_USERNAME, DB_PASSWORD, array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC));
+ /// $this->user_id = $_SESSION['user_session'];
+
+ $this->pdo = $GLOBALS['DB_con'];
+
+ }
+
+
+ /** Close Instance
+ * --------------------------------------------------- (probably not needed)
+ */
+ public function close()
+ {
+ // Default
+ // $this->pdo=Database::disconnect();
+ }
+
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## CATEGORY METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** get all categories
+ *
+ * raw table data (simplest SELECT)
+ *
+ */
+ public function get_categories()
+ {
+ $sql = "SELECT * FROM wiki_category ORDER BY title";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+
+ /** constuct a category_tree
+ *
+ * returns a tree representation of the categories
+ *
+ * NOTE:
+ * category_tree() is an expensive method;
+ * it calls 2 other methods implementing recursive algorithms
+ * thus it uses many sources to run (particularly RAM).
+ * Caching the result is strogly recommended.
+ *
+ */
+ public function category_tree()
+ {
+ $categories = $this->get_categories(); // get all categories
+
+ $tree = $this->to_tree($categories); // format to a tree
+
+ $tree_wParents = $this->tree_parents($tree); // add parents section for each tree-node
+
+ return $tree_wParents;
+ }
+
+
+ /** to_tree
+ *
+ * constructs a tree from raw-table data;
+ * this is a private method and uses a recursive algorithm
+ *
+ * @param $dataset (array): flar array of records with id/parent-id pairs
+ * @return $root (array): id of root category
+ *
+ * (**) each node has 2 parts:
+ * .... .. rec : all record attributes/data as passed into $dataset
+ * .... .. childs : array of (children) nodes
+ */
+ public function to_tree($dataset, $root = 0)
+ {
+ $return = [];
+
+ // loop data ; search for direct children of root
+ foreach($dataset as $key => $rec) {
+
+ $child = $rec['id'];
+ $parent = $rec['parent_id'];
+
+ if ($parent == $root) { // a direct child is found
+
+ unset($dataset[$key]); // remove item (no need to traverse again)
+
+ // Append the child into result array ; parse its children
+ $return[] = [
+ 'rec' => [
+ 'id' => $rec['id'],
+ 'title' => $rec['title'],
+ 'parent' => $rec['parent_id']
+ ],
+ 'childs' => $this->to_tree($dataset, $child) // recursively
+ ];
+ }
+ }
+ return empty($return) ? [] : $return;
+ }
+
+
+ /** tree_parents
+ *
+ * adds a section to each tree node with all parents of each node
+ *
+ * @param $tree (array) : nodes array (each node has `rec` and `childs` sections )
+ * @param $parents (array); DO NOT SET IT (takes values automaticaly)
+ * @return array of nodes with an extra node[parents] section
+ *
+ */
+ private function tree_parents($tree, $parents = [])
+ {
+ $tree_with_parents = [];
+
+ foreach($tree as $key => $node) {
+ // parents to be pushed for node's children
+ $push_parents = $parents; // parents so far
+ $push_parents[] = $node['rec']; // this record will be a new parent
+
+ $tree_with_parents[$key] = [
+ 'rec' => $node['rec'],
+ 'parents' => $parents,
+ 'childs' => ($node['childs'] == [])
+ ? []
+ : $this->tree_parents($node['childs'], $push_parents)
+ ];
+ }
+
+ return $tree_with_parents;
+ }
+
+
+ /** add_category()
+ *
+ * add new category
+ *
+ * @param $recuest (array): [ title => , parent_id => ]
+ * @return true;
+ *
+ * TODO: catch database error -> return succes => false
+ */
+ public function add_category($request)
+ {
+ $sql = "INSERT INTO wiki_category (title, parent_id) VALUES (:title, :parent_id)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([
+ 'title' => $request['title'],
+ 'parent_id' => $request['parent_id']
+ ]);
+ $inserted_id = $this->pdo->lastInsertId();
+
+ return [ "success" => true, 'id' => $inserted_id ];
+ }
+
+
+ /** update_category()
+ *
+ * update an existing category
+ *
+ * @param $recuest (array): [ id => , title => , parent_id => ]
+ * @return true;
+ *
+ * TODO: catch database error -> return false
+ */
+ public function update_category($request)
+ {
+ $sql = "UPDATE wiki_category SET title = :title, parent_id = :parent_id WHERE id = :id";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([
+ 'title' => $request['title'],
+ 'parent_id' => $request['parent_id'],
+ 'id' => $request['id']
+ ]); ;
+
+ return true;
+ }
+
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## POST (ARTICLE) METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** get_post
+ *
+ * get a certain post (given the post's `id`)
+ *
+ * @param $id (int)
+ * @param $only_publised (bool): should the post be published? (default=true)
+ */
+ public function get_post($id, $only_published = true) {
+
+ $where_clause = ($only_published) ? ' AND post.status = 1' : '';
+
+ // depricated: $sql = "SELECT * FROM post WHERE id = :id {$where_clause}";
+ // Select post
+ // + inject tags json
+ // TODO: + inject media
+ // ---
+ $sql = "SELECT wiki_post.*,
+ ( SELECT CONCAT('[', GROUP_CONCAT(JSON_OBJECT('id', wiki_tag.id, 'name', wiki_tag.name)), ']')
+ FROM wiki_tag
+ WHERE wiki_tag.id IN ( SELECT tag_id FROM wiki_post_tags WHERE post_id = :id )
+ ) AS tags_json,
+ ( SELECT
+ CONCAT('[', GROUP_CONCAT(JSON_OBJECT(
+ 'id', wiki_media.id,
+ 'title', wiki_media.title,
+ 'type', wiki_media.type,
+ 'path', wiki_media.path,
+ 'reference', wiki_post_media.reference )),
+ ']')
+ FROM wiki_media
+ LEFT JOIN wiki_post_media ON wiki_post_media.media_id = wiki_media.id
+ WHERE wiki_post_media.post_id = :id
+ ) AS medias_json
+ FROM wiki_post
+ WHERE wiki_post.deleted IS NULL AND wiki_post.id = :id";
+
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(['id' => $id]);
+
+ $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ return ($result == [])
+ ? false // no result? false
+ : $result[0]; // else return 1st record
+ }
+
+
+ /** add post
+ *
+ * insert post to database
+ *
+ * @param $data (array): an array with all post-properties
+ * @return: an array with `status` (bool), `id` (int) given to post
+ */
+ public function add_post($data)
+ {
+ $post = [
+ 'title' => $data['title'],
+ 'category_id' => $data['category_id'],
+ 'body' => $data['body'],
+ 'intro' => $data['intro'],
+ 'status' => $data['status'],
+ 'userid' => $this->user_id
+ ];
+
+ $sql = "INSERT INTO wiki_post (`title`, category_id, `body`, intro, `status`, user_id)
+ VALUES (:title, :category_id, :body, :intro, :status, :userid)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($post);
+
+ $inserted_id = $this->pdo->lastInsertId();
+
+ // create + link tags to the post
+ // --- -- -- - - -
+ if (isset($data['tags'])) { // existing tags...
+ $this->set_post_tags($inserted_id, $data['tags']);
+ }
+ if (isset($data['new_tags'])) { // new tags...
+ $this->create_tags_for_post($data['new_tags'], $inserted_id);
+ }
+
+ // set medias for post
+ // --- -- -- - - -
+ if (isset($data['media'])) { // new tags...
+ $this->create_medias_for_post($data['media'], $inserted_id);
+ }
+
+ return [ "success" => true, 'id' => $inserted_id ];
+ }
+
+
+ /** update post
+ *
+ * update post record to database
+ *
+ * @param $data (array): an array with all post-properties
+ */
+ public function update_post($data)
+ {
+ $post = [
+ 'id' => $data['id'],
+ 'title' => $data['title'],
+ 'category_id' => $data['category_id'],
+ 'body' => $data['body'],
+ 'intro' => $data['intro'],
+ 'status' => $data['status']
+ ];
+ $sql = "UPDATE wiki_post
+ SET `title` = :title, category_id = :category_id,
+ `body` = :body, intro = :intro,
+ `status` = :status
+ WHERE id = :id";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($post);
+
+ // create + link tags to the post
+ // --- -- -- - - -
+ if (isset($data['tags'])) { // old tags...
+ $this->set_post_tags($post['id'], $data['tags']);
+ }
+ if (isset($data['new_tags'])) { // new tags...
+ $this->create_tags_for_post($data['new_tags'], $post['id']);
+ }
+
+ // set medias for post
+ // --- -- -- - - -
+ if (isset($data['media'])) {
+ $this->remove_media_from_post($post['id']); // remove old
+ $this->create_medias_for_post($data['media'], $post['id']); // re-create new
+ }
+
+ return true;
+ }
+
+
+ /** latest_published_posts
+ *
+ * latest PUBLISHED posts of a category (by category_id)
+ *
+ * TODO: handle sorting
+ *
+ * @param $category_id (int) ; if 0 then no category is specified
+ * @param $limit (int) ; 0 = no limit
+ * @param $fieldset (string): list of fields; default all = '*'
+ */
+ public function latest_published_posts($category_id, $limit = 10, $fieldset = '*')
+ {
+
+ $where_clause = ($category_id)
+ ? " AND category_id = :categoryid "
+ : " ";
+
+ $limit_clause = ($limit)
+ ? " LIMIT ". $limit
+ : "";
+
+ $sql = "SELECT {$fieldset} FROM wiki_post
+ WHERE `status` = 1 AND deleted IS NULL ". $where_clause
+ ." ORDER BY creation_date DESC ". $limit_clause;
+
+ $stmt = $this->pdo->prepare($sql);
+
+ if (!$category_id) {
+ $stmt->execute();
+
+ } else {
+ $stmt->execute(['categoryid' => $category_id]);
+ }
+
+ $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ return ($result === null)
+ ? false // no result? return false
+ : $result; // else return results
+ }
+
+
+ /** delete post
+ *
+ * NOTE:
+ * post is *marked* as deleted;
+ * a cron-job will make the actual deletions in a later time
+ *
+ * @param $id (int): post id
+ */
+ public function delete_post($id)
+ {
+ if (intval($id) == 0) { // no valid id?
+ return ["success" => false]; // return false
+ }
+
+ $sql = "UPDATE wiki_post SET deleted = 1 WHERE id = :id";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(['id' => $id]);
+
+ return ["success" => true];
+ }
+
+
+ /** all posts
+ * published + unpublished ; any category ; no limit
+ *
+ * @param $filedset (string): list of fields; default all = '*'
+ */
+ public function all_posts($fieldset = '*')
+ {
+
+ $sql = "SELECT {$fieldset} FROM wiki_post WHERE deleted IS NULL";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+
+ $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ return ($result === null)
+ ? false // no result? return false
+ : $result; // else return results
+ }
+
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## MEDIA METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+ /** create media for post
+ *
+ * links post to each media-file of the media `id`s array
+ * @param $media (array): a list of media-file `id`s
+ * @param $post_id (int)
+ */
+ private function create_medias_for_post( $medias, $post_id )
+ {
+ foreach($medias as $medi_string) {
+ $medi = explode(';', $medi_string); // [0]: media_id, [1]: reference
+ $this->link_media_to_post($medi[0], $post_id, $medi[1]); // link to post
+ }
+ return true;
+ }
+
+ /** remove_media_from_post
+ *
+ * remove all media records/attachments from post
+ *
+ * @param $post_id (int)
+ */
+ private function remove_media_from_post( $post_id )
+ {
+ $sql = "DELETE FROM wiki_post_media WHERE post_id = :postid";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(['postid' => $post_id]);
+ return true;
+ }
+
+ /** link one media-file to a specific post
+ *
+ * NOTE:
+ * the method does not check if media is linked already
+ * so be sure that the pair of (media_id,post_id) not exist
+ *
+ * @param $media_id (int)
+ * @param $post_id (int)
+ * @param $reference (int|bit)
+ */
+ private function link_media_to_post( $media_id, $post_id, $reference )
+ {
+ $sql = "INSERT INTO wiki_post_media (post_id, media_id, reference) VALUES (:postid, :mediaid, :reference)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([
+ 'postid' => $post_id,
+ 'mediaid' => $media_id,
+ 'reference' => $reference
+ ]);
+ return true;
+ }
+
+
+
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## FILE METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** upload_file_to_folder
+ *
+ * upload the file to CloudStorage
+ * in a virtual folder
+ *
+ * @param $folder
+ */
+ public function upload_file_to_folder($folder)
+ {
+ // Checks before uploading the file
+ ////////////////////////////////////////////////////////////////////////
+
+ // ** 1: file is upladed to temporary folder ---------------------------
+ if (! is_uploaded_file($_FILES['file']['tmp_name'])) {
+ return false; // bye!
+ }
+
+ // ** 2: File belongs to the allowed MIME types ------------------------
+ $allowed_file_types = array(
+ 'application/pdf',
+ 'image/png', 'image/jpeg',
+ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ );
+ // Recomended MIME type checking via mime_content_type():
+ $mime_type = mime_content_type($_FILES['file']['tmp_name']);
+ if (! in_array($mime_type, $allowed_file_types)) { // File type NOT allowed ...
+ return false; // bye!
+ }
+
+ $file_name = $_FILES['file']['name'];
+ $file_type = $_FILES['file']['type']; // do not take it for granted
+ $file_size = $_FILES['file']['size'];
+ $file_tmp = $_FILES['file']['tmp_name'];
+
+ $bare_name = pathinfo($file_name, PATHINFO_FILENAME);
+ $file_ext = pathinfo($file_name, PATHINFO_EXTENSION);
+
+
+ // ** 3: filename or size checks may be added --------------------------
+ if ($file_name == "") {
+ return false; // bye!
+ }
+
+ // READY to finaly save/upload the file to CDN /////////////////////////
+
+ $store_filename = $folder .'/'. strtolower($this->clear_file_name($bare_name) .'.'. $file_ext);
+
+ $file_destination = $this->wiki_files_root .'/'. $store_filename;
+
+ // depricated; it is autoloaded include_once CLOUDSTORAGE;
+ $cloudstorage = new CloudStorage();
+
+ if ($cloudstorage->upload_object($file_destination, $file_tmp)) {
+
+ return [
+ 'path' => $file_destination,
+ 'type' => $mime_type
+ ];
+
+ } else { return false; }
+ }
+
+ /** uc_split
+ *
+ * this is an mb_str_split polyfill because
+ * pythia uses a php version < 7.4
+ *
+ * CHECK: https://www.php.net/manual/en/function.mb-str-split.php
+ */
+ private function uc_split($string, $length = 1, $encoding = 'UTF-8' )
+ {
+ if(!empty($string)){
+ $split = array();
+ $mb_strlen = mb_strlen($string,$encoding);
+ for($pi = 0; $pi < $mb_strlen; $pi += $length){
+ $substr = mb_substr($string, $pi,$length,$encoding);
+ if( !empty($substr)) {
+ $split[] = $substr;
+ }
+ }
+ }
+ return $split;
+ }
+
+ /** clear_file_name
+ * replace greek characters and strip symbols
+ */
+ private function clear_file_name($str)
+ {
+ $el = $this->uc_split("ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωάέήίόύώϊϋς ");
+ $en = str_split( "ABGDEZHUIKLMNJOPRSTYFXCVabgdezhuiklmnjoprstyfxcvaehioyviys-");
+ $strip = str_split("!@#$%^&*()+~`[]{};'/<>?=\"");
+
+ return str_replace($strip, '', str_replace($el, $en, $str));
+ }
+
+
+ /** define_media
+ *
+ * create a record in media table
+ *
+ * @param $data
+ * @return id (int): id of created media record
+ */
+ public function define_media($data)
+ {
+ $sql = "INSERT INTO wiki_media (`title`, `type`, `path`)
+ VALUES (:title, :mimetype, :filepath)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([
+ 'title' => $data['title'],
+ 'mimetype' => $data['type'],
+ 'filepath' => $data['path']
+ ]);
+ $inserted_id = $this->pdo->lastInsertId();
+
+ return $inserted_id;
+ }
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## TAG METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+ /** all tags
+ *
+ * @return: (array) all tags
+ */
+ public function all_tags()
+ {
+ $sql = "SELECT * FROM wiki_tag";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+
+ $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ return ($result === null)
+ ? false // no result? return false
+ : $result; // else return results
+ }
+
+
+ /** create_tag
+ *
+ * @param $tag (string): the tag label
+ * @return : id (int) given to tag
+ */
+ public function create_tag($tag)
+ {
+ $sql = "INSERT INTO wiki_tag (`name`) VALUES (:label)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([ 'label' => $tag ]);
+
+ $tag_id = $this->pdo->lastInsertId();
+
+ return $tag_id;
+ }
+
+ /** destroy_tag
+ *
+ * @param $tag_id (int)
+ */
+ public function destroy_tag($id)
+ {
+ // remove all post-links to this tag
+ $clean_posts = "DELETE FROM wiki_post_tags WHERE tag_id = :tagid";
+ $clean = $this->pdo->prepare($clean_posts);
+ $clean->execute([ 'tagid' => $id ]);
+
+ // remove tag from tags table
+ $sql = "DELETE FROM wiki_tag WHERE id = :tagid";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([ 'tagid' => $id ]);
+
+ return true;
+ }
+
+ /** posts_of_tag
+ *
+ * return posts linked to the specified tag (by tag-id)
+ *
+ * @param tag_id
+ * @param limit
+ * @param fieldset (string): list of post field-names separated with a comma (,)
+ * @param only_published (bool)
+ */
+ public function posts_of_tag($tag_id, $limit = 0, $fieldset = '*', $only_published = true)
+ {
+ // format post field-names for SELECT SQL
+ // post_fields will be somethning like... `post.*`
+ // or... `post.id, post.title, post.into`
+ // --- -- -- - - -
+ $fields = explode(',', str_replace(' ', '', $fieldset));
+ $fields_arr = [];
+ foreach($fields as $f) { $fields_arr[] = 'wiki_post.'. $f; }
+ $post_fields = implode(', ', $fields_arr);
+
+ $limit_clause = ($limit)
+ ? " LIMIT ". $limit
+ : "";
+
+ $filter = ($only_published)
+ ? " AND post.status = 1 "
+ : "";
+
+ $sql = "SELECT {$post_fields} FROM wiki_post
+ LEFT JOIN wiki_post_tags ON wiki_post_tags.post_id = wiki_post.id
+ LEFT JOIN wiki_tag ON wiki_tag.id = wiki_post_tags.tag_id
+ WHERE wiki_post.deleted IS NULL AND wiki_tag.id = :tagid {$filter}
+ {$limit_clause}";
+
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(['tagid' => $tag_id]);
+
+ $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ return $result;
+ }
+
+
+ /** tag_name
+ * return tag name (name = label / title / text)
+ * by tag_id
+ *
+ * @param $id (int): given tag-id
+ * @return (string)
+ */
+ public function tag_name($id) {
+ $sql = "SELECT `name` FROM wiki_tag WHERE id = :id LIMIT 1";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(['id' => $id]);
+
+ $result = $stmt->fetch();
+
+ return $result['name'];
+
+ }
+
+ /** set_tags_to_post()
+ *
+ * REMOVE OLD tags from post;
+ * then SET NEW tags
+ *
+ * NOTE:
+ * make sure that any old tags are removed from post;
+ * new tags should not be linked in a later cycle
+ *
+ */
+ private function set_post_tags($post_id, $tags)
+ {
+ $this->remove_tags_from_post($post_id); // remobve any old tags
+
+ foreach($tags as $tag) { // set each tag to post
+ $this->link_tag_to_post($tag, $post_id);
+ }
+ }
+
+ /** remove_tags_from_post
+ *
+ * @param $post_id (int)
+ */
+ private function remove_tags_from_post( $post_id )
+ {
+ $sql = "DELETE FROM wiki_post_tags WHERE post_id = :postid";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute(['postid' => $post_id]);
+ return true;
+ }
+
+ /** set one tag to a specific post
+ *
+ * NOTE:
+ * the method does not check if tag is linked already
+ *
+ */
+ private function link_tag_to_post( $tag_id, $post_id )
+ {
+ $sql = "INSERT INTO wiki_post_tags (post_id, tag_id) VALUES (:postid, :tagid)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([
+ 'postid' => $post_id,
+ 'tagid' => $tag_id
+ ]);
+ return true;
+ }
+
+ /** create tags for post
+ *
+ * creates an array of tags; links post to each one;
+ * @param $tags (array): a list of tags (labels/texts)
+ * @param $post_id (int)
+ */
+ private function create_tags_for_post( $tags, $post_id )
+ {
+ foreach($tags as $tag) {
+ $tag_id = $this->create_tag($tag); // create tag
+ $this->link_tag_to_post($tag_id, $post_id); // link to post
+ }
+ return true;
+ }
+
+
+ public function tags_stats()
+ {
+ $sql = "SELECT tag.id, tag.name, COUNT(post_tags.post_id) as totals
+ FROM wiki_tag
+ LEFT JOIN wiki_post_tags ON wiki_post_tags.tag_id = wiki_tag.id
+ GROUP BY wiki_tag.id";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute();
+
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## USER METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+ /** user_info
+ *
+ * no user is passed; user info are exrtacted from Session
+ *
+ */
+ private function user_info()
+ {
+ $user = [];
+ $keys = [
+ 'user_session' => 'user',
+ 'employee_id' => 'employee',
+ 'store_id' => 'store',
+ 'site_id' => 'site',
+ 'unit_id' => 'unit',
+ 'employee_rID' => 'rid'
+ ];
+
+ foreach($keys as $key => $attribute) {
+ if (isset($_SESSION[$key]) && ($_SESSION[$key] != '')) {
+ $user[$attribute] = $_SESSION[$key];
+ }
+ }
+ return $wiki_user;
+ }
+
+
+ /** update post stats
+ *
+ */
+ public function update_post_stats($post_id)
+ {
+
+ $user_id = $_SESSION['user_session']; // get user from session
+
+ $sql = "INSERT INTO wiki_stats
+ (post_id, user_id, views) VALUES (:post, :user, 1)
+ ON DUPLICATE KEY UPDATE views = views + 1";
+
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([
+ 'user' => $user_id,
+ 'post' => $post_id
+ ]);
+
+ return true;
+
+ }
+
+}
diff --git a/public/app/views/simple/Render.php b/public/app/views/simple/Render.php
new file mode 100644
index 0000000..79a93a0
--- /dev/null
+++ b/public/app/views/simple/Render.php
@@ -0,0 +1,182 @@
+<?php
+/** Wiki Render Class
+ * -----------------------------------------------------------------------------
+ *
+ * + automates the creation of common html elements used in Wiki
+ *
+ * + methods are called statically (without object-instantiation)
+ *
+ * + Render class constructs the Render::all_breadcrumbs() super-array
+ *
+ * -----------------------------------------------------------------------------
+ */
+class Render_example
+{
+
+ /** Render::template($data)
+ * (as any render function in anom framework)
+ *
+ * @param $file (string) : filename [with path] of a php/html (acting as template)
+ * @param $data : array of variable-name:value pairs (extracted/used into template)
+ */
+ static function template($file, $data) {
+
+ if (file_exists($file)) {
+ extract( $data );
+ require( $file );
+
+ } else {
+ echo "<!-- view ". $file ." is missing -->";
+ }
+
+ }
+
+
+ /** all_breadcrumbs
+ * -------------------------------------------------------------------------
+ *
+ * returns an array of all breadcrumbs
+ * where array-key of each record is category[id]
+ *
+ * NOTE:
+ * ---
+ * Render::all_breadcrumbs returns an indexed super-array;
+ * each array item includes a banch of information: [
+ * breadcrumb,
+ * rec: [ id , title ],
+ * parents: [ [id, title] , ... ]
+ * childs: [ [id, title] , ... ],
+ * level
+ * ]
+ *
+ * Use Cases:
+ * ---
+ * as a super-array, the output can be used in many cases
+ * for example...
+ * into <select-option> form elements
+ * .. while selecting category for a post
+ * .. or editing a category
+ * or directry referring to category's parents/childs
+ *
+ * Arguments:
+ * ---
+ * @param $tree (array) : category tree (with childs and parents parts)
+ * @param $detimiter (string, optional) : string to split breadcrumb's path-nodes
+ * @param $exception (int, optional) : id of category to exclude (subcategories shall be excluded too)
+ * @param $l (int, not-pass) : depth level of the node; DO NOT SET (takes values automaticaly)
+ * @return array of breadcrumbs
+ * -------------------------------------------------------------------------
+ */
+ static public function all_breadcrumbs($tree, $delimiter = " / ", $exception = 0, $l = 0)
+ {
+ $all = []; // results array
+
+ foreach($tree as $node) { // loop through all nodes
+
+ if (intval($node['rec']['id']) != $exception) { // if node is not exception
+
+ // construct breadcrumb html of node
+ // --- -- -- - - -
+ $breadcrumb = "";
+ foreach($node['parents'] as $par) { // first: join path titles
+ $breadcrumb .= $par['title'] . $delimiter;
+ }
+ $breadcrumb .= $node['rec']['title']; // last: append title
+
+ // make a new super record
+ // --- -- -- - - -
+ $all[$node['rec']['id']] = [ // set record is as key
+ 'breadcrumb' => $breadcrumb, // add breadcrump to results
+ 'rec' => $node['rec'], // + node info
+ 'parents' => $node['parents'], // + parents array
+ 'childs' => self::first_level_childs($node), // + direct childs
+ 'level' => $l // + level
+ ];
+
+ // recursively traverse children nodes
+ // --- -- -- - - -
+ if (isset($node['childs']) && $node['childs'] != []) {
+ $child_breadcrumbs = self::all_breadcrumbs(
+ $node['childs'],
+ $delimiter,
+ $exception,
+ $l+1
+ );
+
+ $all = $all + $child_breadcrumbs; // concatenate arrays (keep array-keys)
+ }
+ }
+
+ }
+ return $all;
+
+ }
+
+ /** first_level_childs
+ * --- -- -- - - -
+ * used by all_breadcrumbs()
+ */
+ static private function first_level_childs($node)
+ {
+ $childs = [];
+ if ($node['childs'] == []) {
+ return [];
+ }
+ foreach($node['childs'] as $key => $kid) {
+ $childs[] = [
+ 'id' => $kid['rec']['id'],
+ 'title' => $kid['rec']['title']
+ ];
+ }
+ return $childs;
+ }
+
+
+ /** date_box
+ * creates a pretty date box; outputs greek months
+ *
+ * @param $date (string) in 'Y-m-d H:i:s' format
+ * @return html (string) of the date-box
+ */
+ static public function date_box($date)
+ {
+ $months_Gr = ['',
+ 'Ιαν', 'Φεβ', 'Μαρ', 'Απρ',
+ 'Μάι', 'Ιουν', 'Ιουλ', 'Αυγ',
+ 'Σεπ', 'Οκτ', 'Νοε', 'Δεκ'
+ ];
+ $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date);
+ $day = $date->format("j");
+ $month = $months_Gr[intval($date->format("m"))];
+ $year = $date->format("Y");
+
+ // print_r([$date, $day, $month, $year]); die();
+
+ return "<div class='date'>
+ <p class='day'>{$day}</p>
+ <p class='month'>{$month}</p>
+ <p class='year'>{$year}</p>
+ </div>";
+ }
+
+ /** date_friendly
+ * outputs an unformated greek-language date string
+ *
+ * @param $date (string) in 'Y-m-d H:i:s' format
+ */
+ static public function date_friendly($date, $display_time = false)
+ {
+ $months_Gr = ['',
+ 'Ιαν', 'Φεβ', 'Μαρ', 'Απρ', 'Μάι', 'Ιουν',
+ 'Ιουλ', 'Αυγ', 'Σεπ', 'Οκτ', 'Νοε', 'Δεκ'
+ ];
+ $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date);
+ $day = $date->format("j");
+ $month = $months_Gr[intval($date->format("m"))];
+ $year = $date->format("Y");
+ $time = ($display_time) ? $date->format("H:i") : '';
+
+ return "{$day} {$month} {$year} {$time}";
+ }
+
+}
diff --git a/public/app/views/simple/category.php b/public/app/views/simple/category.php
new file mode 100644
index 0000000..15d8b5e
--- /dev/null
+++ b/public/app/views/simple/category.php
@@ -0,0 +1,156 @@
+<?php
+// init app
+// -----------------------------------------------------------------------------
+include 'pythia.php';
+
+
+// read request parameters
+// -----------------------------------------------------------------------------
+$category_id = intval($_GET['id']);
+
+
+// Get all data needed
+// (so you don't have to make multiple requests to the wiki-database)
+// -----------------------------------------------------------------------------
+
+$categories = $wiki->category_tree();
+$breadcrumbs = Render::all_breadcrumbs($categories);
+
+// get ALL posts of category
+$posts = $wiki->latest_published_posts($category_id, 0);
+
+
+// special case:
+// -----------------------------------------------------------------------------
+// ONE post + NO subcaegories ..then redirect to the only post
+if (($breadcrumbs[ $category_id ]['childs'] == []) && (count($posts) == 1)) {
+ header('Location: /wiki/post?id='. $posts[0]['id'], true, 302);
+ die();
+}
+
+
+// find parent_ids of current category (to open the menu tree)
+$category_path = [];
+foreach($breadcrumbs[$category_id]['parents'] as $parent) {
+ $category_path[] = intval($parent['id']);
+}
+$category_path[] = intval($category_id);
+
+
+// format $posts array
+// ---
+$posts_formated = [];
+foreach($posts as $post) {
+ $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $post['creation_date']);
+ $posts_formated[] = [
+ 'id' => $post['id'],
+ 'title' => $post['title'],
+ 'intro' => $post['intro'] ?? false,
+ 'date' => Render::date_box($post['creation_date'])
+ ];
+}
+
+
+// then render the page ...
+// -----------------------------------------------------------------------------
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>Wiki</title>
+
+ <?php Render::template("sections/common_libs.php", [ 'admin' => false ]); ?>
+
+</head>
+<body>
+ <?php include HEAD; ?>
+
+ <div class="container-fluid main-content wiki">
+
+ <div class="top-bar">
+ <div class="breadcrumb">
+ <?php // breadcrumbs
+ ////////////////////////////////////////////////////////////
+ Render::template("sections/breadcrumbs.php", [
+ 'id' => $category_id,
+ 'title' => $breadcrumbs[ $category_id ]['rec']['title'],
+ 'parents' => $breadcrumbs[ $category_id ]['parents']
+ ]); ////////////////////////////////////////////////////
+ ?>
+ </div>
+
+ <div class="admin">
+ <?php include "sections/admin-dropdown.php"; ?>
+ </div>
+
+ </div><!-- /top-bar -->
+
+ <div class="row">
+ <div class="col-xl-2 col-lg-3 col-sm-4 side-bar">
+
+ <!-- <h3>Κατηγορίες</h3> -->
+ <?php // categories hierarchical menu
+ ////////////////////////////////////////////////////////////
+ Render::template("sections/categories_menu.php", [
+ 'categories' => $categories,
+ 'open_path' => $category_path
+ ]); ////////////////////////////////////////////////////
+ ?>
+
+ </div>
+ <div class="col-xl-10 col-lg-9 col-sm-8 content">
+
+ <div class="content--wrapper">
+
+ <!-- Category Title -->
+ <h2>
+ <?=$breadcrumbs[ $category_id ]['rec']['title']?>
+ </h2>
+
+
+ <!-- Sub-Categories -->
+
+ <?php if ($breadcrumbs[ $category_id ]['childs'] != []) : ?>
+
+ <h3>Υποκατηγορίες</h3>
+ <div class="category-childs">
+
+ <?php foreach($breadcrumbs[ $category_id ]['childs'] as $key => $child) : ?>
+
+ <a href="/wiki/category?id=<?=$child['id']?>">
+ <?=$child['title']?>
+ </a>
+
+ <?php endforeach; ?>
+
+ </div>
+
+ <?php endif; ?>
+
+
+ <!-- Articles List -->
+
+ <?php if ($posts) : ?>
+
+ <h3>Άρθρα / Δημοσιεύσεις</h3>
+
+ <?php // list of posts/articles
+ ////////////////////////////////////////////////////
+ Render::template("sections/articles_list.php", [
+ 'articles' => $posts_formated,
+ 'fieldset' => ['intro', 'date']
+ ]);
+ ////////////////////////////////////////////////////
+ ?>
+ <?php endif; ?>
+
+
+ </div>
+
+ </div>
+ </div>
+ </div>
+
+ <script src="/wiki/js/wiki.js"></script>
+</body>
+</html>
diff --git a/public/app/views/simple/index.php b/public/app/views/simple/index.php
new file mode 100644
index 0000000..9860db0
--- /dev/null
+++ b/public/app/views/simple/index.php
@@ -0,0 +1,123 @@
+<?php
+// init app
+// -----------------------------------------------------------------------------
+include 'pythia.php';
+
+// Get all data needed
+// (so you don't have to make multiple requests to the wiki-database)
+// -----------------------------------------------------------------------------
+$categories = $wiki->category_tree();
+$breadcrumbs = Render::all_breadcrumbs($categories);
+$posts = $wiki->latest_published_posts(0, 7); // 0 = no category ; 7 = posts limit
+
+// format posts array; inject breadcrumbs
+// ---
+$posts_formated = [];
+foreach($posts as $post) {
+ $posts_formated[] = [
+ 'id' => $post['id'],
+ 'title' => $post['title'],
+ 'category' => $breadcrumbs[ $post['category_id'] ]['breadcrumb'],
+ 'intro' => $post['intro'] ?? false,
+ 'date' => Render::date_box($post['creation_date'])
+ ];
+}
+
+// optional fields to be printed into post-list
+// ---
+$fieldset = [
+ 'date',
+ 'intro',
+ 'category'
+];
+
+// then render the page ...
+// -----------------------------------------------------------------------------
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>Wiki</title>
+
+ <?php Render::template("sections/common_libs.php", [ 'admin' => false ]); ?>
+
+</head>
+<body>
+ <?php include HEAD; ?>
+
+ <div class="container-fluid main-content wiki">
+
+ <div class="top-bar">
+
+ <div class="breadcrumb">
+ <?php // breadcrumbs
+ ////////////////////////////////////////////////////////////
+ Render::template("sections/breadcrumbs.php", [
+ 'id' => 0,
+ 'title' => '',
+ 'parents' => []
+ ]); ////////////////////////////////////////////////////////
+ ?>
+ </div>
+
+ <div class="admin">
+ <?php include "sections/admin-dropdown.php"; ?>
+ </div>
+
+ </div><!-- /top-bar -->
+
+ <div class="row">
+
+ <div class="col-xl-2 col-lg-3 col-sm-4 side-bar">
+
+ <!-- <h3>Κατηγορίες</h3> -->
+ <?php // categories hierarchical menu
+ ////////////////////////////////////////////////////////////
+ Render::template("sections/categories_menu.php", [
+ 'categories' => $categories,
+ 'open_path' => []
+ ]); ////////////////////////////////////////////////////
+ ?>
+
+ </div><!-- /side-bar -->
+
+ <div class="col-xl-10 col-lg-9 col-sm-8 content">
+
+ <!-- ARTICLES LIST -->
+ <div class="content--wrapper">
+
+ <h2>
+ Welcome to Wiki!
+ </h2>
+
+
+ <p>Ίσως κάποιο εισαγωγικό κείμενο, στατικό ή δυναμικό.
+ Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus.
+ </p><p>
+ Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.
+ </p>
+
+
+ <h3>Τελευταίες Δημοσιεύσεις</h3>
+
+ <?php // list of posts/articles
+ ////////////////////////////////////////////////////////
+ Render::template("sections/articles_list.php", [
+ 'articles' => $posts_formated,
+ 'fieldset' => $fieldset
+ ]);
+ ////////////////////////////////////////////////////////
+ ?>
+
+ </div>
+
+
+ </div><!-- /content -->
+
+ </div>
+
+ </div> <!-- /main-content wiki -->
+
+ <script src="/wiki/js/wiki.js"></script>
+</body>
+</html>
diff --git a/public/app/views/simple/lorem-dates.php b/public/app/views/simple/lorem-dates.php
new file mode 100644
index 0000000..7a7c000
--- /dev/null
+++ b/public/app/views/simple/lorem-dates.php
@@ -0,0 +1,18 @@
+<?php
+function some_date() {
+ $dt = new DateTime();
+ $dt->setTimestamp(time() - rand(0,30000)*1000 + rand(1,1000) );
+
+ // return time() - rand(0,30000)*1000 + rand(1,1000);
+ return $dt->format('Y-m-d H:i:s');
+}
+
+
+include 'pythia.php';
+
+$posts = $wiki->latest_posts(0, 900);
+
+foreach($posts as $post) {
+ $wiki->set_date($post['id'], some_date());
+}
+echo "done!"; \ No newline at end of file
diff --git a/public/app/views/simple/lorem-intros.php b/public/app/views/simple/lorem-intros.php
new file mode 100644
index 0000000..a3629e1
--- /dev/null
+++ b/public/app/views/simple/lorem-intros.php
@@ -0,0 +1,721 @@
+<?php
+
+define("LOREM", [
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sit amet venenatis urna. Mauris dictum justo diam, quis aliquet massa accumsan placerat. Vivamus eu dapibus erat. Fusce at pretium enim. Curabitur eget pulvinar lectus. Sed bibendum lectus nisi, eget elementum elit laoreet sed. Curabitur pretium dapibus magna sed rhoncus. Donec at velit eget metus posuere tincidunt.'
+ ,
+ 'Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.'
+ ,
+ 'Fusce libero lorem, tristique id nunc vel, pellentesque consectetur lacus. Aenean elit elit, euismod at neque ac, dapibus efficitur lacus. Sed fermentum vehicula luctus. Sed luctus condimentum magna id aliquet. Duis eget justo ut nunc condimentum tempus non at metus.'
+ ,
+ 'Nullam imperdiet tempus odio at pretium. Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus. Integer augue felis, ullamcorper in volutpat et, maximus eget erat.'
+ ,
+ 'Suspendisse turpis sapien, cursus non aliquam sed, accumsan sed arcu. Sed pellentesque ipsum metus. Maecenas placerat enim non massa laoreet convallis. Etiam quis dolor interdum, condimentum ipsum vitae, ullamcorper nulla. Nulla ornare volutpat sagittis. Morbi at sapien nibh.'
+ ,
+ 'Curabitur nec quam placerat, placerat elit sit amet, tincidunt neque. Pellentesque nec maximus neque. Aenean maximus quam in sem aliquet, lobortis blandit sem lobortis. Aliquam id venenatis tortor, quis sollicitudin turpis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.'
+ ,
+ 'Phasellus suscipit turpis nec consequat euismod. Vivamus eleifend sapien quis mollis scelerisque. Cras et nunc sed turpis dictum porta. Cras vel scelerisque ex, ac volutpat dui. Donec eleifend porta est sit amet fringilla.'
+ ,
+ 'Nunc sed purus sed nibh lobortis luctus a vitae ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ac erat accumsan, faucibus lorem et, euismod ex. Fusce cursus nulla lacus, ac varius neque tempus.'
+ ,
+ 'Phasellus consequat purus eu porta sagittis. In hac habitasse platea dictumst. Maecenas lorem diam, scelerisque non neque euismod, lobortis congue massa.'
+ ,
+ 'Nunc sed purus sed nibh lobortis luctus a vitae ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ac erat accumsan, faucibus lorem et, euismod ex. Fusce cursus nulla lacus, ac varius neque tempus. In pulvinar facilisis ornare.'
+ ,
+ 'Etiam sit amet nisi accumsan, tincidunt nisi at, malesuada dolor. Donec consequat metus turpis, eu suscipit libero consequat ac. Praesent malesuada sagittis vestibulum. Aenean gravida in elit et ornare. Pellentesque non bibendum est. Cras augue dolor, euismod ut faucibus at, suscipit eu erat. Suspendisse interdum nisl eget nibh vehicula, et commodo urna rhoncus. Nunc sed faucibus libero.'
+ ,
+ 'Nam et quam dolor. Suspendisse quis ante dapibus, porta est sed, accumsan risus. Etiam faucibus, augue id sollicitudin blandit, nisi nisl feugiat nibh, fringilla pharetra quam eros sed eros.'
+ ,
+ 'Praesent ullamcorper blandit tortor, non sollicitudin erat viverra ultrices. Quisque in vehicula leo. Proin ut urna quis quam vestibulum bibendum ut at odio. Duis ullamcorper leo et aliquet tempor. Pellentesque id orci aliquet, condimentum sapien nec, porttitor felis. Mauris fringilla tellus eu hendrerit tempor. Maecenas viverra nunc ligula, id interdum ipsum dapibus quis.'
+ ,
+ 'Vestibulum congue libero nisi, vitae dapibus turpis mollis at. Vestibulum ante urna, consequat quis efficitur non, iaculis at metus. Curabitur sagittis turpis sed odio feugiat, rhoncus posuere neque rhoncus. Nunc dignissim commodo tortor, in lacinia nisi imperdiet in.'
+ ,
+ 'Suspendisse cursus, metus pharetra faucibus mattis, orci leo efficitur enim, vel malesuada dui ante vitae lacus. Duis eget quam sodales, feugiat risus ut, feugiat turpis.'
+ ,
+ 'Quisque sollicitudin risus tellus, eu gravida nisi commodo sit amet. Sed nec libero nec metus auctor mattis eu tristique diam. Aenean metus nisl, blandit nec mollis quis, luctus at nunc.'
+ ,
+ 'Pellentesque id orci aliquet, condimentum sapien nec, porttitor felis. Mauris fringilla tellus eu hendrerit tempor. Maecenas viverra nunc ligula, id interdum ipsum dapibus quis. Vestibulum congue libero nisi, vitae dapibus turpis mollis at.'
+ ,
+ 'Vestibulum ante urna, consequat quis efficitur non, iaculis at metus. Curabitur sagittis turpis sed odio feugiat, rhoncus posuere neque rhoncus. Nunc dignissim commodo tortor, in lacinia nisi imperdiet in. Suspendisse cursus, metus pharetra faucibus mattis, orci leo efficitur enim, vel malesuada dui ante vitae lacus. Duis eget quam sodales'
+ ,
+ 'Duis ullamcorper leo et aliquet tempor. Pellentesque id orci aliquet, condimentum sapien nec, porttitor felis. Mauris fringilla tellus eu hendrerit tempor.'
+ ,
+ 'Maecenas viverra nunc ligula, id interdum ipsum dapibus quis. Vestibulum congue libero nisi, vitae dapibus turpis mollis at. Vestibulum ante urna, consequat quis efficitur non, iaculis at metus. Curabitur sagittis turpis sed odio feugiat, rhoncus posuere neque rhoncus. Nunc dignissim commodo tortor, in lacinia nisi imperdiet in. Suspendisse cursus, metus pharetra faucibus mattis, orci leo efficitur enim, vel malesuada dui ante vitae lacus.'
+ ,
+ 'Eu suscipit libero consequat ac. Praesent malesuada sagittis vestibulum. Aenean gravida in elit et ornare. Pellentesque non bibendum est. Cras augue dolor, euismod ut faucibus at, suscipit eu erat. Suspendisse interdum nisl eget nibh vehicula, et commodo urna rhoncus. Nunc sed faucibus libero. Nam et quam dolor.'
+ ,
+ 'Suspendisse quis ante dapibus, porta est sed, accumsan risus. Etiam faucibus, augue id sollicitudin blandit, nisi nisl feugiat nibh, fringilla pharetra quam eros sed eros.'
+ ,
+ 'Ut vitae justo eu velit aliquam fringilla in et magna. Ut suscipit varius nunc, ut accumsan massa sollicitudin sed. Suspendisse sit amet egestas libero. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec porta dui ut felis aliquet eleifend a eget metus.'
+ ,
+ 'Nulla et dui ex. Suspendisse potenti. Quisque dictum tristique mi, ac commodo sem ullamcorper eget. Donec sagittis augue sit amet metus aliquet, ac blandit mi convallis. Nulla semper vel erat at sagittis. Nullam eu massa nibh. Integer nec sem consequat arcu cursus condimentum. Duis commodo, enim eget vestibulum aliquam, quam nibh interdum lacus, id feugiat lacus augue at magna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Cras dictum id nunc sit amet efficitur.'
+ ,
+ 'Suspendisse potenti. Ut ullamcorper lectus dui, non dignissim est efficitur quis. Mauris a tempus eros, quis accumsan lacus. Aliquam commodo feugiat pretium. Aenean tincidunt sollicitudin sem sit amet elementum.'
+ ,
+ 'In rutrum, lorem vel vestibulum ultricies, purus lectus varius nibh, sit amet vulputate risus elit et lectus. Quisque lectus tortor, varius sed tincidunt sit amet, cursus non risus.'
+ ,
+ 'Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ac erat accumsan, faucibus lorem et, euismod ex. Fusce cursus nulla lacus, ac varius neque tempus a. In pulvinar facilisis ornare.'
+ ,
+ 'Mauris dui metus, lacinia sit amet faucibus ut, vehicula et urna. Vestibulum at sem sed turpis eleifend venenatis. Mauris euismod nisl nibh. Etiam gravida purus mi, varius sollicitudin nunc iaculis a. Nunc egestas venenatis risus, sed interdum velit tincidunt quis. Praesent mattis magna eget condimentum convallis. Nulla urna justo, pulvinar a sagittis nec, feugiat vel ex. Sed quis malesuada lorem.'
+ ,
+ 'Mauris dui metus, lacinia sit amet faucibus ut, vehicula et urna. Vestibulum at sem sed turpis eleifend venenatis. Mauris euismod nisl nibh.'
+ ,
+ 'Etiam gravida purus mi, varius sollicitudin nunc iaculis a. Nunc egestas venenatis risus, sed interdum velit tincidunt quis. Praesent mattis magna eget condimentum convallis. Nulla urna justo, pulvinar a sagittis nec, feugiat vel ex. Sed quis malesuada lorem.'
+ ,
+ 'Quisque lectus arcu, cursus vel pellentesque sit amet, faucibus vitae turpis. Vestibulum mauris augue, lacinia a mollis at, placerat ut eros. Suspendisse gravida tempor lacus, ac tincidunt lectus facilisis ut. Morbi lacinia dui at fermentum viverra.'
+ ,
+ 'Integer dapibus lorem a ligula vestibulum dapibus. Suspendisse potenti. Maecenas tincidunt risus nec volutpat tincidunt. Vivamus tincidunt mauris nec nunc accumsan tristique. Maecenas mattis porttitor malesuada. Aliquam lobortis eu ligula et aliquet.'
+ ,
+ 'Praesent ut leo tristique tellus commodo sagittis nec eget nibh. Ut ut lacus laoreet, semper turpis ac, dictum nulla. In tincidunt ut ipsum in accumsan.'
+ ,
+ 'Nunc ac neque nec sapien pretium tempus. Maecenas consectetur ut libero quis vulputate. Donec consectetur viverra est vel consectetur. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Curabitur vel ullamcorper diam.'
+ ,
+ 'Pellentesque posuere dui in metus rutrum, sed accumsan tellus rhoncus. Nam facilisis nibh hendrerit leo condimentum dictum. Maecenas in egestas nisi, at tristique nunc.'
+ ,
+ 'Nulla sit amet venenatis urna. Mauris dictum justo diam, quis aliquet massa accumsan placerat. Vivamus eu dapibus erat. Fusce at pretium enim. Curabitur eget pulvinar lectus. Sed bibendum lectus nisi, eget elementum elit laoreet sed. Donec at velit eget metus posuere tincidunt.'
+ ,
+ 'Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.'
+ ,
+ 'Sed luctus condimentum magna id aliquet. Duis eget justo ut nunc condimentum tempus non at metus. Nullam imperdiet tempus odio at pretium. Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus.'
+ ,
+ 'Suspendisse turpis sapien, cursus non aliquam sed, accumsan sed arcu. Sed pellentesque ipsum metus. Maecenas placerat enim non massa laoreet convallis.'
+ ,
+ 'Etiam quis dolor interdum, condimentum ipsum vitae, ullamcorper nulla. Nulla ornare volutpat sagittis. Morbi at sapien nibh. Curabitur nec quam placerat, placerat elit sit amet, tincidunt neque. Pellentesque nec maximus neque. Aenean maximus quam in sem aliquet, lobortis blandit sem lobortis.'
+ ,
+ 'Aliquam id venenatis tortor, quis sollicitudin turpis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus suscipit turpis nec consequat euismod. Vivamus eleifend sapien quis mollis scelerisque. Cras et nunc sed turpis dictum porta. Cras vel scelerisque ex, ac volutpat dui. Donec eleifend porta est sit amet fringilla. Sed luctus condimentum magna id aliquet. Duis eget justo ut nunc condimentum tempus non at metus. Nullam imperdiet tempus odio at pretium.'
+ ,
+ 'Aliquam id venenatis tortor, quis sollicitudin turpis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus suscipit turpis nec consequat euismod. Vivamus eleifend sapien quis mollis scelerisque.'
+ ,
+ 'Cras et nunc sed turpis dictum porta. Cras vel scelerisque ex, ac volutpat dui. Donec eleifend porta est sit amet fringilla. Sed luctus condimentum magna id aliquet.'
+ ,
+ 'Duis eget justo ut nunc condimentum tempus non at metus. Nullam imperdiet tempus odio at pretium.'
+ ,
+ 'Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus.'
+ ,
+ 'Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.'
+]);
+
+function some_lorem_intro() {
+
+ $lorem = LOREM;
+
+ shuffle($lorem);
+
+ return $lorem[0];
+}
+
+$text = "Nigeria delays plans to replace its banknotes
+Twitter restricted in Turkey, firm says
+2 children died, six others injured after bus crashes into daycare near Montreal
+Lockerbie bombing suspect pleads not guilty
+Internal police documents detail misconduct of officers charged in Tyre Nichols' death
+Big Oil faces scrutiny after huge profit jump
+'You don't belong here': See tense confrontation between Romney and Santos
+Meghan and Harry will be deposed in Samantha Markle's defamation lawsuit: judge
+Why I'm embracing the joy in Sam Smith's kingdom of queer delights
+How companies are trying to help victims of the earthquakes
+TotalEnergies hits pause on new Adani deal
+Australia rejects coal mine near Great Barrier Reef
+Joe Rogan under fire for antisemitism in his defense of Rep. Ilhan Omar
+UK competition regulator warns Microsoft's Activision deal could harm millions
+Google plans to use AI in search results
+Mexico bans 'shark tourism'
+Jim Carrey lists $29M LA mansion while offering a glimpse of his own art
+Morning light from window of airplane wing
+Expert reveals the safest seat on an airplane
+A pest control technician on a routine call recently unraveled
+Massive trove of acorns cleverly stashed in the walls of a California home.
+700 pounds of acorns found inside walls of California home
+'Liar!': Marjorie Taylor Greene interrupts Biden's speech
+Opinion: The terrible diet that unites Nancy Pelosi and Donald Trump
+Kyrie Irving #11 of the Brooklyn Nets dribbles against the Boston Celtics during the first half at TD Garden
+Kyrie Irving's trade causes ripple effect across the NBA
+Earthquake death toll surpasses 11,000
+TOPSHOT - Rescuers and civilians look for survivors under the rubble of collapsed buildings in Kahramanmaras
+Close to the quake's epicentre
+The day after a 7.8-magnitude earthquake struck the country's southeast
+Rescuers in Turkey and Syria braved frigid weather, aftershocks and collapsing buildings
+As they dug for survivors buried by an earthquake that killed more than 5,000 people.
+Some of the heaviest devastation occurred near the quake&#39;s epicentre between Kahramanmaras and Gaziantep
+A city of two million where entire blocks now lie in ruins under gathering snow.
+Aid agencies and emergency workers in Turkey and Syria say the number is likely to increase
+People remain trapped amid freezing conditions
+Why was the earthquake so deadly?
+Hope and despair as rescuers search for survivors in city
+Video shows residents being pulled from earthquake debris
+Sister shields sibling from dust during 36-hour wait for help
+I tried Microsoft's new AI-powered Bing. Here's what it's like
+Google details plans to use AI in search results
+Uber reports 'strongest quarter ever' while the rest of Silicon Valley stalls
+Turkey's stock market halted after earthquake leads to sharp selloff
+Here's what keeps Jerome Powell up at night and interest rates high
+US-China trade defies talk of decoupling to hit record high in 2022
+Two rail unions reach deal with CSX railroad for paid sick time
+Sen. Daines' Twitter account suspended after posting profile picture of himself hunting
+Chinese savers stashed away $2.6 trillion last year but property crash will cool 'revenge spending'
+Bed Bath & Beyond closes stores and raises $1 billion to stave off bankruptcy
+Microsoft unveils revamped Bing search engine using AI technology more powerful than ChatGPT
+Zoom will lay off 1,300 employees and CEO is taking a massive pay cut
+IRS suggests waiting to file your taxes if you got a special payment from your state in 2022
+Want your tax refund faster? Here's what to do
+Offices are more than 50% filled for the first time since the pandemic started
+US Labor Department accuses Amazon of failing to keep warehouse workers safe
+Disney just threw down the gauntlet in the WFH battle
+This former tech worker is helping change laws for people who get laid off
+What to expect at work this year
+Twitter restricted in Turkey, according to network monitoring firm
+I tried Microsoft's new AI-powered Bing. Here's what it's like
+Google details plans to use AI in search results
+Uber reports 'strongest quarter ever' while the rest of Silicon Valley stalls
+Sen. Daines' Twitter account suspended after posting profile picture of himself hunting
+Microsoft unveils revamped Bing search engine using AI technology more powerful than ChatGPT
+Zoom will lay off 1,300 employees and CEO is taking a massive pay cut
+Democratic senator urges Apple and Google to ban TikTok from their app stores
+ChatGPT creator launches subscription service for viral AI chatbot
+Meta shares surge nearly 20% as Zuckerberg pledges to make 2023 a 'year of efficiency'
+Apple and Google's app stores wield 'gatekeeper' power and should be reined in, Commerce Department says
+Apple is the only US tech giant to have avoided significant layoffs. Will it last?
+ChatGPT creator rolls out 'imperfect' tool to help teachers spot potential cheating
+State of the Union, recession, crypto woes and more tech layoffs: What investors are watching
+The CEO of America's second-largest bank is preparing for possible US debt default
+FTX to politicians: Give us back our donations or we'll sue you
+The bear market could make a comeback
+This dwarf planet has a ring instead of a moon, and scientists don't know why
+Codebreakers find and decode lost letters of Mary, Queen of Scots
+Earth core structure. Elements of this image furnished by NASA
+Hidden molten rock layer found beneath Earth's tectonic plates
+Edible crab (Cancer pagurus) on sand covered with algi during low tide.
+Neanderthals had a taste for a seafood delicacy that's still popular today
+Jupiter now has 92 moons after new discovery
+New asteroid photobombs Webb telescope
+Tesla roadster launched from the Falcon Heavy rocket with a dummy driver
+SpaceX put a Tesla sportscar into space five years ago. Where is it now?
+The last full moon of the decade, with a saffron-colored tinge, better known as the full hovers over Los Angeles
+How to see February's full snow moon
+Cosmic seaplanes and self-growing bricks could help us explore other worlds
+Sci-fi ideas that could change the future of space exploration
+Green comet seen from Earth for first time since Stone Age
+A 319-million-year-old brain has been discovered. It could be the oldest of its kind
+Neanderthals hunted massive elephants that once roamed northern Europe
+Watch green comet pass Earth for the first time since Stone Age
+Workers feed cows at a dairy farming company in Handan, Hebei Province, China
+China says it successfully cloned 3 highly productive 'super cows'
+Viking burial mound at Heath Wood being excavated.
+Vikings brought their animals with them to Britain over 1,000 years ago
+Supernova reveals rare pair of stars believed to be one of only about 10 like it in the Milky Way
+Vessels from the embalming workshop
+Discovery of embalming workshop reveals how ancient Egyptians mummified the dead
+Meteor showers bring 'astronomy to us'
+Green comet will appear in the night sky for the first time since the Stone Age
+Mysterious flying whirlpool captured in night sky over Hawaii
+Evolutionary biologist Dolph Schluter is pictured at one of his ponds containing stickleback
+Fish at the University of British Columbia.
+Meet the man who has transformed our understanding of evolution
+The best photos of Mars
+Eastern chimpanzee juvenile male aged 8 years playing with his brother schweinfurtheii)
+Gombe National Park, Tanzania. June 2012.
+Does having a teen feel like living with a chimpanzee?
+You may not be far off, study shows
+CNN Exclusive: Inside the secretive process to select the first astronauts for NASA's next moon mission
+Facing the extremes as an Arctic photographer
+While opening a new window on the origin of more complex molecules that are the first step in the creation of the building blocks of life.
+A large, dark cloud is contained within the frame.
+In its top half it is textured like smoke and has wispy gaps
+At the bottom and at the sides it fades gradually out of view.
+On the left are several orange stars: three each with six large spikes
+Behind the cloud which colours it pale blue and orange.
+Many tiny stars are visible, and the background is black.
+Observing the universe with the James Webb Space Telescope
+The AD-1 oblique wing research aircraft was photographed during a wing sweep test flight.
+The aircraft was flown 79 times during the research program conducted at NASA Dryden between 1979 and 1982.
+Why NASA tested a plane with a pivoting wing
+Newly discovered asteroid makes one of the closest approaches of Earth
+NASA shows rise in near-Earth asteroids
+Professor breaks down why Earth's inner core may have stopped
+Scientists have learned the primates employ gestures that follow some of the same rules intrinsic to human language.
+Experts made the discovery after studying videos of wild chimps living in Uganda
+Humans can understand apes' sign language, new study finds
+Fossils reveal the mysterious primate relatives that lived in the ancient Arctic
+Anna Torv, Bella Ramsey, Pedro Pascal the Last of Us episode 2
+What scientists say about the real-life zombie fungi that inspired 'The Last of Us'
+Rocket Lab&#39;s Electron rocket lifts off at its new pad, called Launch Complex 2
+Rocket Lab launches Electron rocket from the US for the first time
+Artist concept of Demonstration for Rocket to Agile Cislunar Operations (DRACO) spacecraft
+Which will demonstrate a nuclear thermal rocket engine.
+Nuclear thermal propulsion technology could be used for future NASA crewed missions to Mars.
+NASA to test nuclear thermal rocket engine for the first time in 50 years
+The Doomsday Clock reveals how close we are to total annihilation
+Mummified 'golden boy' found covered in 49 precious amulets
+Rare 17-pound meteorite discovered in Antarctica
+Webb telescope peers into the frozen heart of a space cloud
+Trilobites armed with tridents could be the earliest known example of sexual combat
+Like a house constantly under renovation, we are ever-changing and replacing old parts with new ones
+Water, proteins and even cells.
+We are all made of stars: The long trip from the big bang to the human body
+Discovery of 250 eggs suggests that giant dinosaurs weren&#39;t caring parents
+Discovery in India reveals intimate details about lives of some of the largest dinosaurs
+South Korea's lunar probe captures stunning Earth, moon images
+Video: UFO-shaped cloud stuns eyewitnesses in Turkey
+Origins of plague could have emerged centuries before outbreaks, new study suggests
+After a historic first mission, what does the future hold for this controversial rocket?
+Car-size laser deflects lightning atop a mountain in Switzerland
+Astronomers have released a gargantuan survey of the galactic plane of the Milky Way.
+The new dataset contains a staggering 3.32 billion celestial objects — arguably the largest such catalog so far.
+Billions of celestial objects captured by new survey of the Milky Way
+A whale belonging to one of the rarest species is 'likely to die,' after entanglement, NOAA says
+One of the 92 fossilized dinosaur nests discovered at a site in central India.
+Unusual dinosaur fossil discovery made in India
+A SpaceX Falcon 9 launches a GPS III satellite to orbit January 18, 2023
+SpaceX launches next-generation GPS satellite
+Short-beaked Echidna Tachyglossus aculeatus. (Photo by: Avalon/Universal Images Group via Getty Images)
+This egg-laying mammal blows bubbles to cool off
+Bronze Age family harvesting grain, as depicted by artist Nikola Nevenov.
+In parts of Ancient Greece, first-cousin marriage was not only allowed but encouraged, DNA shows
+Tiny, foot-long dwarf boa identified in Ecuador is new to science
+Foot-long dwarf boa found in Ecuadorian Amazon
+The SpaceX Falcon Heavy rocket is launched on classified mission USSF-67 for the U.S. Space Force at Cape Canaveral, Florida, U.S. January 15, 2023. REUTERS/Joe Skipper
+SpaceX's most powerful rocket returns to flight and nails synchronized landing
+Illustration reflects the conclusion that the exoplanet LHS 475
+Rocky and almost precisely the same size as Earth.
+The planet whips around its star in just two days, far faster than any planet in the Solar System.
+Researchers will follow up this summer with additional observations with Webb
+LHS 475 b is relatively close, 41 light-years away, in the constellation Octans.
+Illustration of a planet and its star on a black background.
+The planet is large, in the foreground at the centre, and the star is smaller
+The planet is rocky. The top quarter of the planet (the side facing the star) is lit, while the rest is in shadow.
+The star is bright yellowish-white, with no clear features.
+2 Earth-size worlds revealed beyond our solar system
+Scientists have taken a fresh look at the fossil flower, which was first documented in 1872 and then largely forgotten about.
+Unusually large fossilized flower preserved in amber identified
+An artistic rending of the star Gaia17bpp being partially eclipsed by the dust cloud surrounding a smaller companion star.
+Unusually brightening star captures attention as a stellar oddity
+Illustration reflects the conclusion that the exoplanet LHS 475 b is rocky and almost precisely the same size as Earth.
+The planet whips around its star in just two days, far faster than any planet in the Solar System.
+Researchers will follow up, they hope will allow them to definitively conclude if the planet has an atmosphere.
+LHS 475 b is relatively close, 41 light-years away, in the constellation Octans.
+The planet is large, in the foreground at the centre, and the star is smaller, in the background and also at the centre.
+The planet is rocky.
+The top quarter of the planet (the side facing the star) is lit, while the rest is in shadow.
+The star is bright yellowish-white, with no clear features.
+James Webb Space Telescope discovers its first exoplanet
+Alken Enge (Denmark) -- at this exceptional site the remains of at least 380 individuals
+Victims of an armed conflict, were deposited almost 2000 years ago.
+Brutality of prehistoric life revealed by Europe's bog bodies
+The Soyuz MS-22 crew ship is pictured on Oct. 8, 2022
+Foreground docked to the Rassvet module as the International Space Station orbited 264 miles above Europe.
+Background, is the Prichal docking module attached to the Nauka multipurpose laboratory module.
+Roscosmos will send replacement spacecraft to return crew to Earth after Soyuz leak
+Wonders of the universe
+Rocket start-up fails attempt to launch satellites off Alaska's coast
+Newly discovered Earth-size planet TOI 700 e orbits within the habitable zone of its star in this illustration.
+Its Earth-size sibling, TOI 700 d, can be seen in the distance.
+Second potentially habitable Earth-size planet found orbiting nearby star
+A galactic merger brought a pair of supermassive black holes together
+A green comet will appear in the night sky for the first time in 50,000 years
+Cosmic Girl 747 sits on the runway surrounded by technical service equipment
+Virgin Orbit's LauncherOne rocket suffers failure on first launch attempt from the UK
+Earth Radiation Budget Satellite (ERBS)
+Reentered Earth'ss atmosphere at
+Dead NASA satellite returns to Earth after 38 years
+View of the Pantheon in the historic centre of Rome
+Mystery of why Roman buildings have survived so long has been unraveled, scientists say
+Why ancient Roman structures like the Pantheon still stand
+'Astonishing' snowy owl spotted in Southern California neighborhood
+People stand beside St Michaels Tower as they watch the full moon, sometimes known as Wolf Moon
+Rise behind Glastonbury Tor in Glastonbury, Britain
+What to expect from tonight's wolf moon
+New space missions will launch to the moon, Jupiter and a metal world in 2023
+Homo heidelbergensis pair wearing cave bear skins for protection from the cold.
+Stone Age humans stepped out in cave bear fur 300,000 years ago
+New image of the Serpens constellation glitters with starlight
+Walt Cunningham adjusts his pressure suit before the Apollo 7 launch on October 11, 1968.
+Last surviving Apollo 7 astronaut has died
+Look up to see January's first celestial event, the Quadrantid meteor shower
+All of the moments and discoveries that provided us with wonder in 2022
+In this 30 second exposure, a meteor streaks across the sky during the annual Perseid meteor shower
+Keep an eye on the sky for 2023's celestial events
+Cirrhilabrus finifenmaa
+Meet a rainbow fish and other new species discovered in 2022
+NASA images showcase eerie beauty of winter on Mars
+Close up photograph of the mammal foot among the ribs of Microraptor
+These may be used in any article in association with this story.
+Rare evidence that dinosaurs feasted on mammals uncovered
+A 15-metric ton meteorite crashed in Africa. Now 2 new minerals have been found in it
+Astronauts complete spacewalk after space debris triggered one-day delay
+The European robotic arm controlled by cosmonaut Anna Kikina surveys the Soyuz MS-22
+Crew ship after the detection of a leak that cancelled the spacewalk
+NASA and Russia weigh options for astronaut return after spacecraft leak
+Sea turtle amputee rescued from net entanglement finds forever home
+Groundbreaking Mars mission comes to an end
+Milky Way over Hawes in the Yorkshire Dales National Park, UK. Meteor from Ursids Meteor shower visible.
+Faint aurora visible on the horizon as a red tinge.
+Near new moon creates perfect viewing conditions for the Ursids meteor shower
+Artist&#39;s life reconstruction of adult and newly born Triassic ichthyosaurs Shonisaurus, 2022.
+Paleontologists solve mystery of fossil death bed
+Doomed exoplanet will be obliterated as it spirals into a star
+The Perseverance rover is about to have a big first on Mars
+Perseverance rover is about to build a first-of-its-kind depot on Mars
+'Game changer' satellite will measure most of the water on the planet
+A diplodocus skeleton at the Carnegie Museum of Natural History in Pittsburgh.
+Could these dinosaurs whip their tails faster than the speed of sound?
+Soyuz spacecraft docked to International Space Station springs 'fairly significant' coolant leak
+'Rail cars' of material released after NASA spacecraft hit asteroid
+Juno mission captured this infrared view of Jupiters volcanic moon
+The most volcanic world in the solar system is about to be visited by a NASA spacecraft
+Dazzling galactic diamonds shine in new Webb telescope image
+A bright meteor can be seen during the Geminids meteor shower
+Massive Martian dust devil passed over the Perseverance rover, and it recorded the eerie sounds
+The shipwreck was discovered at a depth of about 1,350 feet (411 meters) and was captured in sonar imagery.
+Researchers aim to return next year with an ROV to capture footage of the wreck.
+Medieval ship found in Norway's biggest lake
+Historic moon mission ends with splashdown of Orion capsule
+Watch NASA's Orion spacecraft splashdown in Pacific Ocean
+Historic moon mission concludes with splashdown of Orion capsule
+A two million- year-old trunk from a larch tree still stuck in the permafrost within the coastal deposits.
+The tree was carried to the sea by the rivers that eroded the former forested landscape.
+A lost ecosystem revealed in Greenland by oldest environmental DNA
+Ankylosaurs used their sledgehammer tails to fight each other
+Japanese fashion mogul Yusaku Maezawa is seen in Tokyo on January 07, 2022.
+Full crew for SpaceX's privately funded moon mission announced
+A year only lasts 17.5 hours on the 'hell planet'
+This artists impression shows a kilonova produced by two colliding neutron stars.
+While studying the aftermath of a long gamma-ray burst (GRB)
+Two independent teams of astronomers using a host of telescopes in space and on Earth
+Have uncovered the unexpected hallmarks of a kilonova
+The colossal explosion triggered by colliding neutron stars.
+Rare cosmic collision acted like one of the 'factories of gold' in the universe
+The last full moon of the year known as the Cold Moon rises behind Galata tower in Ista
+Full 'cold moon' shines bright and eclipses Mars in a rare event
+Ingenuity Mars Helicopter completed a successful Flight 31 on September
+NASA Ingenuity helicopter just broke one of its own records on Mars
+Artist_s reconstruction of Kap København Formation two million years ago
+Oldest DNA sheds light on a 2 million-year-old ecosystem that has no modern parallel
+Stunning necklace found at burial site of powerful Anglo-Saxon woman
+Necklace reconstruction and layout side by side.
+The Harpole Treasure in photos
+The American lion, otherwise known as Pathera atrox
+The largest extinct cat to live in North America
+Rare ice age fossils discovered on the drought-stricken Mississippi River
+Dinosaur larger than T. rex couldn't swim well despite large fish being on the menu
+San Diego Zoo Wildlife Alliance
+DNA analysis of soil from paw prints could help save Sumatra's tigers
+See photos from the Apollo era like never before
+NASA astronaut and Expedition 68 Flight Engineer
+Frank Rubio
+Pictured during a spacewalk
+Tethered to the International Space Station&#39;s starboard truss structure.
+Astronauts will give the space station a power boost during Saturday spacewalk
+NASA's Viking 1 may have landed at the site of an ancient Martian megatsunami
+Toxoplasmosis, an infection caused by the T. gondii parasite
+Best known in cats but it might also be affecting wolf behavior.
+'Mind control' by parasites influences wolf-pack dynamics in Yellowstone National Park
+NASA's historic moon mission enters the final leg of its journey
+Webb telescope spies clouds beneath the thick haze of Saturn's moon Titan
+Original slate plaque modelled after an owl in the Museo de Huelva.
+Replica of the Valencina Slate Plaque with inserted owl feathers
+Two drilled holes at the top of the plaque.
+Popular toy of prehistoric children revealed by new research
+Rare cosmic event beamed light at Earth from 8.5 billion light-years away
+VP Harris, French President Macron see Webb telescope's latest chaotic image
+The manned spaceship Shenzhou-15, atop the Long March-2F Y15 carrier rocket
+Blasts off from the Jiuquan Satellite Launch Center
+New era begins with China's launch of crewed mission to its space station
+A Daubenton&#39;s bat (Myotis daubentonii) in flight and hunting at night.
+Bats use the same techniques as death metal singers to vocalize, study finds
+The bone fragments of a new giant turtle species
+Have scientists estimating a 3.7-meter-long body (12.1 feet), larger than the size of a car.
+Ancient giant sea turtle with never-before-seen features found in Europe, scientists say
+NASA's Orion spacecraft reaches record-breaking distance from Earth on Artemis I mission
+'It's getting dark': 'Good Night Oppy' recounts the sudden death of a Mars rover
+Newly identified dinosaur that lived on island of dwarfed creatures had an unusual head
+SpaceX launches tomato seeds, other supplies to International Space Station
+Kimberella fossil
+Discovered Russia in 2018, shows evidence of the earliest known animal meal
+Scientists said.
+World's 'oldest meal' discovered in 550-million-year-old fossil
+NASA's Orion spacecraft snaps a selfie on its journey beyond the far side of the moon
+This illustration shows what exoplanet WASP-39 b could look like
+Based on current understanding of the planet.
+New data on 'hot Saturn' exoplanet is a 'game changer,' scientists say
+Angler Andy Hackett is celebrating after catching one of the worlds biggest goldfish.
+The gigantic orange specimen, aptly nicknamed The Carrot, weighed a whopping 67lbs 4ozs.
+Fisherman catches 67-pound goldfish
+Great bustards eat corn poppies for their medicinal properties.
+World's heaviest flying bird uses plants to self-medicate, scientists say
+A view of Shanidar Cave in northern Iraq.
+Neanderthals cooked meals with pulses 70,000 years ago
+Dwarf tomato seeds to launch to space station aboard SpaceX resupply flight
+Sseen from Harbor town Marina on Merritt Island, Fla.
+The moon is visible in the sky.
+Malcolm Denemark/Florida Today via AP
+Historic Artemis I mission is just beginning its lunar journey
+NASA still won't rename the James Webb Space Telescope, citing new investigation into namesake's career
+Mystery parasites on zombie ant fungus identified by scientists
+Sutherland and Fiona, mother-daughter chimpanzees
+Part of the Ngogo chimpanzee community
+The Kibale National Park in Uganda.
+Scientists captured footage of Fiona holding a leaf out to her mother with no clear motive behind it.
+Chimpanzees share experiences with each other, a trait once thought to be only human
+Early observations show the LOFTID demonstration was successful.
+In addition to achieving its primary objective of surviving the intense speed and heating of re-entry
+The aft side of the heat shield was well protected from the heat of re-entry.
+Inflatable aeroshells can keep payloads safe during atmospheric entry.
+Inflatable heat shield a 'huge success' that could land humans on Mars
+Webb telescope finds two of the most distant galaxies ever observed
+A Leonids meteor streaks across the sky over Ankara
+Leonid meteor shower could bring an outburst of up to 250 meteors per hour
+The size of a basketball, this meteorite landed in southeastern England in February 2021.
+Where did Earth's water come from? This meteorite might hold the answer
+Artemis I mission shares spectacular view of Earth after a historic launch
+Cosmic hourglass captured by the James Webb Space Telescope reveals birth of a star
+Space Launch System rocket is seen at Kennedy Space Center
+Historic moon mission troubleshoots fuel leak ahead of launch
+First-time NASA spacewalkers ventured outside the space station Tuesday
+Meet Commander Moonikin Campos, the mannequin going farther than any astronaut
+Why NASA is returning to the moon 50 years later with Artemis I
+Why NASA wants to return to the moon before sending humans to Mars
+Teams from Johnson Space Center, Exploration Ground Systems, and Jacobs TOSC conduct
+Final inspections of Mooniki
+The inside the Space Station Processing Facility at NASA
+Kennedy Space Center in Florida. Moonikin will be installed into the Orion crew module.
+Checked connectivity and performed fit checks on his flight suit
+Ensure he is ready for flight aboard the Artemis flight test.
+Artemis I will be an uncrewed test flight
+The Orion spacecraft and Space Launch System rocket as an integrated system
+Meet Commander Moonikin Campos, the mannequin going farther than any astronaut
+Snoopy, mannequins and Apollo 11 items will swing by the moon aboard Artemis I
+Rocket with the Orion spacecraft aboard is seen at sunrise atop
+Launch team conducts the wet dress rehearsal test at NASA
+What the words you'll hear during the moon mission launch really mean
+Clues at ancient lake site reveal earliest known cooked meal
+The biggest wild card in the climate crisis
+Belching lakes, mystery craters, 'zombie fires'
+How the climate crisis is transforming the Arctic permafrost
+The NASA Moon rocket makes its way from the Vehicle Assembly
+Building headed to Pad 39B
+The Artemis I mission for Monday
+NASA still targets Artemis I launch next week despite minor hurricane damage
+Space Shuttle Challenger remnants discovered underwater by documentary crew
+A bright fireball is seen above Brkini, Slovenia
+Northern Taurid fireballs can be seen all November long
+In close relations to the fungi fairy rings
+There are no fairies at play here.
+One of nature's great mysteries may now have an answer
+Two separate missions launched aboard a United Launch Alliance
+Atlas V rocket from Space Launch
+Complex-3 at Vandenberg Space Force Base in Lompoc
+California on Thursday morning.
+Heat shield that could land humans on Mars is hitching a ride to space
+A portion of the dwarf galaxy Wolf
+Infrared Array Camera (left) and the James Webb Space Telescope
+New Webb telescope image shows 'lonely' dwarf galaxy in striking detail
+International Space Station Cygnus spacecraft. (NASA)
+Cargo spacecraft docks with ISS after solar panel fails to deploy
+Bronze Age comb reveals an ancient frustration with head lice
+Astronomers spy the ghost of a star and cosmic cobwebs
+A space rock slammed into Mars on Christmas Eve. It revealed a hidden surprise
+Antarctic emperor penguins
+Antarctica's emperor penguins at risk of extinction due to the climate crisis
+Over 200 different species of rhododendrons can be found high up in the Hengduan mountains
+Known for its biological diversity
+Estimated 12,000 species of flowers grow on the mountains.
+Evolutionary mystery of harmonious flower meadow may be solved
+Ghostly figures emerge from Pillars of Creation in new Webb telescope image
+Microbes may have survived for millions of years beneath the Martian surface
+One of The rare Aye-Aye lemur that is only nocturnal
+This weird-looking primate's extra-long fingers give it an extra-gross talent
+Webb telescope shares unique peek inside the early universe
+ed dwarfs tend to be magnetically active, and erupt with intense
+Strip a nearby planets atmosphere over time, or make the surface inhospitable.
+The hunt for habitable planets may have just gotten far more narrow, new study finds
+The International Space Station (ISS)
+Roscosmos cosmonaut Pyotr Dubrov from the Soyuz MS-19 spacecraft
+International Space Station swerves to avoid Russian space debris
+The last solar eclipse of the year can be seen today
+Rare 300-foot whaleback boat discovered at the bottom of Lake Superior
+From Comet Halley, Orionid meteor shower is most visible this week
+James Webb Space Telescope captures new details of iconic 'Pillars of Creation'
+Mosquito insect sitting on skin
+Are you a mosquito magnet? This could be why
+A reconstruction of a Neanderthal father and his daughter.
+Ancient DNA reveals first Neanderthal family portrait
+The Black Death is still affecting the human immune system
+The heaviest bony fish in the world is a giant sunfish of 2744 kg
+Discovered in Portugal.
+Record-breaking bony fish weighing 3 tons found
+Torn deck plating of the V 1302 John Mahn that was damaged by the bomb that hit amidships
+World War II shipwreck still pollutes the North Sea's ocean floor 80 years later
+Your cat might also be secretly signaling
+Affection in the way they look at you, writes Emily Blackwell.
+Want to know if your cat loves you? Look out for these signs
+Astronaut James A. McDivitt after successful space flight.
+Astronaut James McDivitt, who led Gemini and Apollo missions, has died at 93
+Bright, powerful burst of gamma rays detected by multiple telescopes
+Elon Musk reverses course, says SpaceX will keep funding Ukraine Starlink service for free
+The 17th litter of cheetah cubs were born at the Smithsonian
+National Zoo and Conservation Biology Institute and can be watched live on their Cheetah Cub Cam.
+Cub cam offers a unique glimpse into the early days of a vulnerable species
+Here's what keeps Jerome Powell up at night and interest rates high
+Disney has bigger problems than Ron DeSantis
+'Britcoin' could arrive in the second half of the decade
+State of the Union, recession, crypto woes and more tech layoffs: What investors are watching
+The CEO of America's second-largest bank is preparing for possible US debt default
+FTX to politicians: Give us back our donations or we'll sue you
+The bear market could make a comeback
+Viva Technologie show at Parc des Expositions Porte de Versailles
+The new international event brings together 5,000 startups
+Companies to grow businesses
+All players in the digital transformation who shape the future of the internet
+A conversation about the post-pandemic workplace with Microsoft CEO Satya Nadella.
+RSVP HERE
+Employee mental health is a huge concern after such a brutal stretch
+How managers really feel about remote work
+Signs an employee may quit and what managers can do to prevent it
+closeup of a young man in an office holding a briefcase and a surgical mask in his hand
+Managers, buckle up. Your job will be harder than ever as we return to the office
+Nervous about socializing in the office again? Here's how to break the ice
+The millennials in sexless marriages
+How we work
+The entry-level workers earning six-figure salaries
+Does 'solo polyamory' mean having it all?
+Family Tree
+The parents who sever ties with their children
+The rising curiosity behind open relationships
+‘Situationships’: Why Gen Z are embracing the grey area
+The jobs employers just can't fill
+Why workers and employers are ghosting each other
+Future Planet
+The enormous heat pumps warming cities
+Montessori: The world's most influential school?
+Should you sing when suffering from a cold?
+The Health Gap
+Why aren't there better treatments for cystitis?
+Get the Facts
+Leprosy: the ancient disease scientists can't solve
+How an ancient Greek myth still shapes our minds
+Wise Words
+Why we need new words for life in the Anthropocene
+Microbes and Me
+Mike loses magic in 'tepid' sequel
+Cinema's ultimate scene-stealers
+EO thumbnail
+Knock at the Cabin is 'passably tense'
+The 1960s crime film that still shocks
+Including the third Ant-Man and Magic Mike films, Cocaine Bear and a Pamela Anderson documentary in which she tells her own story
+Nicholas Barber lists this month's unmissable releases.
+How gut bacteria are controlling your brain
+Bird flu outlook is ‘grim’ as new wave of the virus heads for Britain
+Warning comes as ornithologists call for the government
+Step up testing to monitor the impact of the deadly H5N1 strain
+Bird flu outlook is ‘grim’ as new wave of the virus heads for Britain
+Energy prices to soar again as Jeremy Hunt rejects pleas to halt rise
+Millions will see costs mount by another 40% in April
+Rebate scheme ends and chancellor lets cap go up to £3,000
+Energy prices to soar again as Jeremy Hunt rejects pleas to halt rise
+Disabled rail passengers face restrictions at one in 10 stations
+Stress led to more NHS staff absences than Covid, new figures show
+‘It’s soul destroying’: why so many NHS staff are off sick with burnout
+Stop UK mobile and broadband firms ‘lining their pockets’, urge consumer experts
+Care worker whistleblower outed by Home Office over exploitation claims
+Same-sex marriage row looms over Church of England synod
+Dominic Raab: more civil servants in bullying complaint than previously thought
+Nurses offer to call off strikes if Sunak matches Welsh pay offer
+What will it take to stop the rape and murder of women by men on probation?
+Debt, bad; work, good: ‘pub bore’ beliefs that seal a miserable fate for the poorest
+Who’s going to be triggered by Northanger Abbey? It’s hardly Game of Thrones
+The Observer view on the shameful premium that the poorest pay for their energy
+It’s so easy to cheat with technology that even judges are doing it
+Knock, knock… the energy companies are at the door – cartoon
+A lowly sergeant, but Happy Valley’s Catherine Cawood is top of the cops
+May I have a word about… rummaging around on the Antiques Roadshow
+Chelsea’s gamble on young guns looks like another shot in the dark by US owners
+Restless Pep’s lean and mean Manchester City go in search of tactical truth at Spurs
+England 23-29 Scotland: Six Nations player ratings from Twickenham
+Paquetá responds to Newcastle’s fast start and salvages point for West Ham
+Scotland’s Duhan van der Merwe stuns England to settle Calcutta Cup thriller
+Wales’ Alun Wyn Jones to miss Scotland clash as Ireland turn focus to France
+Championship roundup: Burnley beat Norwich, Birmingham stun Swansea
+Southampton fans turn on Nathan Jones as Brentford cruise to victory
+Jared O’Mara: ex-Labour MP found guilty of six counts of expenses fraud
+Five arrested after video circulates of attack on Surrey schoolgirl
+Brexit Northern Ireland protocol is lawful, supreme court rules
+Archie Battersbee’s death was an accident, coroner concludes
+Meet Jinx, the dog on a mission to protect Welsh bird colonies from rats
+Mary, Queen of Scots prison letters finally decoded
+David Carrick ‘no longer the big man’ after life sentences, says victim
+Mother of UK woman missing with newborn baby issues open letter
+Prevent review condemned for ‘anti-Muslim prejudice
+Surge in young people declaring disability in England and Wales
+Russia-Ukraine war live
+Sunak says ‘nothing off the table’ after Zelenskiy’s plea for fighter jets
+Volodymyr Zelenskiy makes appeal for jets in address to British parliament
+MH17: strong indications Putin signed off on supplying missile that hit plane
+Before and after satellite images show scale of earthquake destruction in Turkey
+Turkey and Syria earthquake death toll nears 12,000 as Erdoğan defends response
+Ukraine releases video appearing to show Russian troops beating own wounded officer
+Split loyalties: coming of age inside Putin's Russia – video
+Canada’s Justin Trudeau greets political opponent with awkward handshake
+How Volodymyr Zelenskiy spent his day in Britain
+Greek PM survives confidence vote but phone-tapping scandal rumbles on
+Greek government faces confidence vote over spying row
+Terrawatch: Santorini watchful as nearby volcano is monitored
+Britain treasures the Parthenon marbles
+Consider this: returned to Greece, could they be more valuable?
+Ex-MEP at heart of cash for influence scandal strikes plea bargain
+Funeral of Greece’s last king, Constantine II, takes place in Athens
+The Guardian view on European migration policy: a cruel, myopic shambles
+Greek court drops spying charges against refugee rescue activists
+Long-awaited trial of 24 aid workers accused of espionage starts in Lesbos
+Caroline Crouch killed by Greek husband because of his drug smuggling, father claims
+Stephen Fry calls for return of Parthenon marbles to Athens
+British Museum in talks with Greece over return of Parthenon marbles
+Greek MEP at centre of Qatar corruption inquiry has hearing postponed
+Pope Francis orders Parthenon marbles held by Vatican be returned to Greece
+A police stakeout, piles of cash, and a promise of reform: the week that shook Brussels
+Former European parliament vice-president Eva Kaili was arrested along with her boyfriend, Francesco Giorgi
+Greece: thousands march after death of Roma boy shot in police chase
+Greek MEP stripped of EU vice-president role amid Qatar scandal
+Police search European parliament offices as bribery inquiry grows
+Afghan refugee freed in Greece after two years of wrongful imprisonment
+Greece passes intelligence bill banning the sale of spyware
+Migrants face ‘unprecedented rise in violence’ in EU borders, report finds
+The fixed-price shopping basket: Greece’s answer to cost of living crisis
+Romany leaders appeal for calm after second day of protest violence in Greece
+Thousands take to streets in Greece in protest over 2008 shooting of teenager
+Violent protests in Greece after Romany boy shot by police
+No 10 rules out law change for return of Parthenon marbles
+Weather tracker: Storm Ariel brings heavy rain and lightning to Greece
+‘Destitution is almost inevitable’: Afghan refugees in Greece left homeless by failed system
+‘Red lights are flashing’: Athens tourism explosion threatens ancient sites
+Smyrna review – raw, shocking violence in epic take on Greek-Turkish conflict
+What happens when an oligarch takes on a prime minister? Look to Greece to find out
+Greek court acquits activists who hung banner at Acropolis in China protest
+US and Israel blame Iran after drone strikes oil tanker off Oman
+Man repatriates 19 antiquities after reading Guardian article";
+
+$titles = explode("\n", $text);
+
+// print_r($titles); die();
+
+
+include 'pythia.php';
+
+
+$posts = $wiki->latest_published_posts(0, 900, 'id, title, intro');
+$tags = $wiki->all_tags();
+
+shuffle($posts);
+shuffle($titles);
+
+
+for ($i = 0 ; $i < 500 ; $i++) {
+ // $wiki->set_intro($posts[$i]['id'], some_lorem_intro());
+ // $wiki->set_status($posts[$i]['id'], 0);
+ // $wiki->set_title($posts[$i]['id'], $titles[$i]);
+ shuffle($tags);
+ $c = ($i % 3) + 1;
+ for ($j = 0; $j < $c; $j ++) { $wiki->link_tag_to_post($tags[$j]['id'], $posts[$i]['id']); }
+}
+echo "done!";
+
+
+
diff --git a/public/app/views/simple/lorem-posts.php b/public/app/views/simple/lorem-posts.php
new file mode 100644
index 0000000..5f7fccb
--- /dev/null
+++ b/public/app/views/simple/lorem-posts.php
@@ -0,0 +1,93 @@
+<?php
+
+define("LOREM", [
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sit amet venenatis urna. Mauris dictum justo diam, quis aliquet massa accumsan placerat. Vivamus eu dapibus erat. Fusce at pretium enim. Curabitur eget pulvinar lectus. Sed bibendum lectus nisi, eget elementum elit laoreet sed. Curabitur pretium dapibus magna sed rhoncus. Donec at velit eget metus posuere tincidunt. Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.'
+ ,
+ 'Fusce libero lorem, tristique id nunc vel, pellentesque consectetur lacus. Aenean elit elit, euismod at neque ac, dapibus efficitur lacus. Sed fermentum vehicula luctus. Sed luctus condimentum magna id aliquet. Duis eget justo ut nunc condimentum tempus non at metus. Nullam imperdiet tempus odio at pretium. Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus. Integer augue felis, ullamcorper in volutpat et, maximus eget erat.'
+ ,
+ 'Suspendisse turpis sapien, cursus non aliquam sed, accumsan sed arcu. Sed pellentesque ipsum metus. Maecenas placerat enim non massa laoreet convallis. Etiam quis dolor interdum, condimentum ipsum vitae, ullamcorper nulla. Nulla ornare volutpat sagittis. Morbi at sapien nibh. Curabitur nec quam placerat, placerat elit sit amet, tincidunt neque. Pellentesque nec maximus neque. Aenean maximus quam in sem aliquet, lobortis blandit sem lobortis. Aliquam id venenatis tortor, quis sollicitudin turpis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus suscipit turpis nec consequat euismod. Vivamus eleifend sapien quis mollis scelerisque. Cras et nunc sed turpis dictum porta. Cras vel scelerisque ex, ac volutpat dui. Donec eleifend porta est sit amet fringilla.'
+ ,
+ 'Nunc sed purus sed nibh lobortis luctus a vitae ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ac erat accumsan, faucibus lorem et, euismod ex. Fusce cursus nulla lacus, ac varius neque tempus.'
+ ,
+ 'Phasellus consequat purus eu porta sagittis. In hac habitasse platea dictumst. Maecenas lorem diam, scelerisque non neque euismod, lobortis congue massa. Nunc sed purus sed nibh lobortis luctus a vitae ante. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ac erat accumsan, faucibus lorem et, euismod ex. Fusce cursus nulla lacus, ac varius neque tempus. In pulvinar facilisis ornare. Etiam sit amet nisi accumsan, tincidunt nisi at, malesuada dolor. Donec consequat metus turpis, eu suscipit libero consequat ac. Praesent malesuada sagittis vestibulum. Aenean gravida in elit et ornare. Pellentesque non bibendum est. Cras augue dolor, euismod ut faucibus at, suscipit eu erat. Suspendisse interdum nisl eget nibh vehicula, et commodo urna rhoncus. Nunc sed faucibus libero. Nam et quam dolor. Suspendisse quis ante dapibus, porta est sed, accumsan risus. Etiam faucibus, augue id sollicitudin blandit, nisi nisl feugiat nibh, fringilla pharetra quam eros sed eros.'
+ ,
+ 'Praesent ullamcorper blandit tortor, non sollicitudin erat viverra ultrices. Quisque in vehicula leo. Proin ut urna quis quam vestibulum bibendum ut at odio. Duis ullamcorper leo et aliquet tempor. Pellentesque id orci aliquet, condimentum sapien nec, porttitor felis. Mauris fringilla tellus eu hendrerit tempor. Maecenas viverra nunc ligula, id interdum ipsum dapibus quis. Vestibulum congue libero nisi, vitae dapibus turpis mollis at. Vestibulum ante urna, consequat quis efficitur non, iaculis at metus. Curabitur sagittis turpis sed odio feugiat, rhoncus posuere neque rhoncus. Nunc dignissim commodo tortor, in lacinia nisi imperdiet in. Suspendisse cursus, metus pharetra faucibus mattis, orci leo efficitur enim, vel malesuada dui ante vitae lacus. Duis eget quam sodales, feugiat risus ut, feugiat turpis. Quisque sollicitudin risus tellus, eu gravida nisi commodo sit amet. Sed nec libero nec metus auctor mattis eu tristique diam. Aenean metus nisl, blandit nec mollis quis, luctus at nunc.'
+ ,
+ 'Pellentesque id orci aliquet, condimentum sapien nec, porttitor felis. Mauris fringilla tellus eu hendrerit tempor. Maecenas viverra nunc ligula, id interdum ipsum dapibus quis. Vestibulum congue libero nisi, vitae dapibus turpis mollis at. Vestibulum ante urna, consequat quis efficitur non, iaculis at metus. Curabitur sagittis turpis sed odio feugiat, rhoncus posuere neque rhoncus. Nunc dignissim commodo tortor, in lacinia nisi imperdiet in. Suspendisse cursus, metus pharetra faucibus mattis, orci leo efficitur enim, vel malesuada dui ante vitae lacus. Duis eget quam sodales'
+ ,
+ 'Duis ullamcorper leo et aliquet tempor. Pellentesque id orci aliquet, condimentum sapien nec, porttitor felis. Mauris fringilla tellus eu hendrerit tempor. Maecenas viverra nunc ligula, id interdum ipsum dapibus quis. Vestibulum congue libero nisi, vitae dapibus turpis mollis at. Vestibulum ante urna, consequat quis efficitur non, iaculis at metus. Curabitur sagittis turpis sed odio feugiat, rhoncus posuere neque rhoncus. Nunc dignissim commodo tortor, in lacinia nisi imperdiet in. Suspendisse cursus, metus pharetra faucibus mattis, orci leo efficitur enim, vel malesuada dui ante vitae lacus.'
+ ,
+ 'Eu suscipit libero consequat ac. Praesent malesuada sagittis vestibulum. Aenean gravida in elit et ornare. Pellentesque non bibendum est. Cras augue dolor, euismod ut faucibus at, suscipit eu erat. Suspendisse interdum nisl eget nibh vehicula, et commodo urna rhoncus. Nunc sed faucibus libero. Nam et quam dolor. Suspendisse quis ante dapibus, porta est sed, accumsan risus. Etiam faucibus, augue id sollicitudin blandit, nisi nisl feugiat nibh, fringilla pharetra quam eros sed eros.'
+ ,
+ 'Ut vitae justo eu velit aliquam fringilla in et magna. Ut suscipit varius nunc, ut accumsan massa sollicitudin sed. Suspendisse sit amet egestas libero. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec porta dui ut felis aliquet eleifend a eget metus. Nulla et dui ex. Suspendisse potenti. Quisque dictum tristique mi, ac commodo sem ullamcorper eget. Donec sagittis augue sit amet metus aliquet, ac blandit mi convallis. Nulla semper vel erat at sagittis. Nullam eu massa nibh. Integer nec sem consequat arcu cursus condimentum. Duis commodo, enim eget vestibulum aliquam, quam nibh interdum lacus, id feugiat lacus augue at magna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Cras dictum id nunc sit amet efficitur.'
+ ,
+ 'Suspendisse potenti. Ut ullamcorper lectus dui, non dignissim est efficitur quis. Mauris a tempus eros, quis accumsan lacus. Aliquam commodo feugiat pretium. Aenean tincidunt sollicitudin sem sit amet elementum. In rutrum, lorem vel vestibulum ultricies, purus lectus varius nibh, sit amet vulputate risus elit et lectus. Quisque lectus tortor, varius sed tincidunt sit amet, cursus non risus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed ac erat accumsan, faucibus lorem et, euismod ex. Fusce cursus nulla lacus, ac varius neque tempus a. In pulvinar facilisis ornare.'
+ ,
+ 'Mauris dui metus, lacinia sit amet faucibus ut, vehicula et urna. Vestibulum at sem sed turpis eleifend venenatis. Mauris euismod nisl nibh. Etiam gravida purus mi, varius sollicitudin nunc iaculis a. Nunc egestas venenatis risus, sed interdum velit tincidunt quis. Praesent mattis magna eget condimentum convallis. Nulla urna justo, pulvinar a sagittis nec, feugiat vel ex. Sed quis malesuada lorem.'
+ ,
+ 'Quisque lectus arcu, cursus vel pellentesque sit amet, faucibus vitae turpis. Vestibulum mauris augue, lacinia a mollis at, placerat ut eros. Suspendisse gravida tempor lacus, ac tincidunt lectus facilisis ut. Morbi lacinia dui at fermentum viverra. Integer dapibus lorem a ligula vestibulum dapibus. Suspendisse potenti. Maecenas tincidunt risus nec volutpat tincidunt. Vivamus tincidunt mauris nec nunc accumsan tristique. Maecenas mattis porttitor malesuada. Aliquam lobortis eu ligula et aliquet. Praesent ut leo tristique tellus commodo sagittis nec eget nibh. Ut ut lacus laoreet, semper turpis ac, dictum nulla. In tincidunt ut ipsum in accumsan. Nunc ac neque nec sapien pretium tempus. Maecenas consectetur ut libero quis vulputate. Donec consectetur viverra est vel consectetur. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Curabitur vel ullamcorper diam. Pellentesque posuere dui in metus rutrum, sed accumsan tellus rhoncus. Nam facilisis nibh hendrerit leo condimentum dictum. Maecenas in egestas nisi, at tristique nunc.'
+ ,
+ 'Nulla sit amet venenatis urna. Mauris dictum justo diam, quis aliquet massa accumsan placerat. Vivamus eu dapibus erat. Fusce at pretium enim. Curabitur eget pulvinar lectus. Sed bibendum lectus nisi, eget elementum elit laoreet sed. Donec at velit eget metus posuere tincidunt. Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.'
+ ,
+ 'Sed luctus condimentum magna id aliquet. Duis eget justo ut nunc condimentum tempus non at metus. Nullam imperdiet tempus odio at pretium. Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus.'
+ ,
+ 'Suspendisse turpis sapien, cursus non aliquam sed, accumsan sed arcu. Sed pellentesque ipsum metus. Maecenas placerat enim non massa laoreet convallis. Etiam quis dolor interdum, condimentum ipsum vitae, ullamcorper nulla. Nulla ornare volutpat sagittis. Morbi at sapien nibh. Curabitur nec quam placerat, placerat elit sit amet, tincidunt neque. Pellentesque nec maximus neque. Aenean maximus quam in sem aliquet, lobortis blandit sem lobortis. Aliquam id venenatis tortor, quis sollicitudin turpis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus suscipit turpis nec consequat euismod. Vivamus eleifend sapien quis mollis scelerisque. Cras et nunc sed turpis dictum porta. Cras vel scelerisque ex, ac volutpat dui. Donec eleifend porta est sit amet fringilla. Sed luctus condimentum magna id aliquet. Duis eget justo ut nunc condimentum tempus non at metus. Nullam imperdiet tempus odio at pretium. Pellentesque non mauris ut orci volutpat sollicitudin ut vel ligula. Nullam et leo eu ante ornare ultrices a a leo. Proin non accumsan ex, sit amet finibus purus. Vestibulum posuere maximus nisl eget consequat. Etiam venenatis lectus sed libero vestibulum viverra. Sed nec nibh ac nulla venenatis tincidunt eu ut diam. Sed tincidunt, arcu sit amet pharetra ornare, libero nunc placerat nunc, at dictum dui diam sit amet metus. Sed venenatis vehicula nisl at hendrerit.'
+]);
+
+function some_lorem_html($min = 2, $max = 9) {
+
+ $lorem = LOREM;
+
+ shuffle($lorem);
+
+ $p = rand($min, $max);
+
+ $html = "";
+
+ for( $i = 0 ; $i < $p ; $i++ ) {
+ $html .= "<p>". $lorem[$i] ."</p>";
+ }
+
+ return $html;
+}
+
+function some_lorem_title() {
+ // make an array of
+ $lorem = explode(".", // sentences only
+ str_replace(". ", ".",
+ implode(" ", LOREM) // made from all Lorem-ipsums
+ )
+ );
+ shuffle($lorem);
+ return $lorem[0];
+}
+
+
+include 'pythia.php';
+
+
+$categories = $wiki->get_categories();
+
+$non_root = [];
+foreach($categories as $node) {
+ if (intval($node['parent_id']) > 0) { $non_root[] = $node['id']; }
+ // if (intval($node['parent_id']) > 5) { $non_root[] = $node['id']; }
+}
+// print_r($non_root); die();
+
+echo "<pre>";
+for( $i = 0 ; $i < 250 ; $i++ ) {
+
+ $temp = $non_root;
+ shuffle($temp);
+
+ $post = [
+ 'title' => some_lorem_title(),
+ 'category' => intval($temp[0]),
+ 'body' => some_lorem_html()
+ ];
+ print_r($post);
+
+ $wiki->add_post($post);
+}
+echo "</pre>"; \ No newline at end of file
diff --git a/public/app/views/simple/post.php b/public/app/views/simple/post.php
new file mode 100644
index 0000000..9e37cd9
--- /dev/null
+++ b/public/app/views/simple/post.php
@@ -0,0 +1,236 @@
+<?php
+// init app
+// -----------------------------------------------------------------------------
+include 'pythia.php';
+
+// read request parameters
+// -----------------------------------------------------------------------------
+$post_id = intval($_GET['id']);
+
+
+// Get all data needed
+// (so you don't have to make multiple requests to the wiki-database)
+// -----------------------------------------------------------------------------
+
+$post = $wiki->get_post($post_id);
+$categories = $wiki->category_tree();
+$breadcrumbs = Render::all_breadcrumbs($categories);
+
+if ($post == false) { // when post not found
+ $post = [ // create a virtual record with error messages
+ 'id' => 0,
+ 'title' => 'Το άρθρο που αναζητήσατε δεν υπάρχει',
+ 'category_id' => 0,
+ 'intro' => 'Για να βρειτε το άρθρο που ψάχνετε χρησιμοποιήστε την αναζήτηση
+ του wiki ή πλοηγηθείτε στο δέντρο των κατηγοριών.',
+ 'body' => ""
+ ];
+
+} else { // post found; prepare article's data to render
+
+ $category_id = $post['category_id'];
+
+ // find parent_ids of current category (to open the menu tree)
+ $category_path = [];
+ foreach($breadcrumbs[$category_id]['parents'] as $parent) {
+ $category_path[] = intval($parent['id']);
+ }
+ $category_path[] = intval($category_id);
+
+}
+
+
+// format special parts of post
+$parsedown = new Parsedown();
+$body_html = $parsedown->text($post['body']); // body: markdown to html
+
+// decode json output (tags + medias)
+if ($post['tags_json'] != null) { $tags = json_decode($post['tags_json']); }
+if ($post['medias_json'] != null) { $medias = json_decode($post['medias_json']); }
+
+
+// then render the page ...
+// -----------------------------------------------------------------------------
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>Wiki</title>
+
+ <?php Render::template("sections/common_libs.php", [ 'admin' => false ]); ?>
+
+<body>
+ <?php include HEAD; ?>
+
+ <div class="container-fluid main-content wiki">
+
+ <div class="top-bar">
+ <div class="breadcrumb">
+
+ <?php if($post['id'] != 0) : ?>
+ <?php // breadcrumbs
+ ////////////////////////////////////////////////////////////
+ Render::template("sections/breadcrumbs.php", [
+ 'id' => $post['category_id'],
+ 'title' => $breadcrumbs[ $post['category_id'] ]['rec']['title'],
+ 'parents' => $breadcrumbs[ $post['category_id'] ]['parents']
+ ]); ////////////////////////////////////////////////////
+ ?>
+ <?php endif ?>
+
+ </div>
+
+ <div class="admin">
+ <?php include "sections/admin-dropdown.php"; ?>
+ </div>
+
+ </div><!-- /top-bar -->
+
+ <div class="row">
+
+ <div class="col-xl-2 col-lg-3 col-sm-4 side-bar">
+
+ <!-- <h3>Κατηγορίες</h3> -->
+ <?php // categories hierarchical menu
+ ////////////////////////////////////////////////////////////
+ Render::template("sections/categories_menu.php", [
+ 'categories' => $categories,
+ 'open_path' => (($post['id'] == 0)
+ ? [1,2,3] // post not exist; open main categories
+ : $category_path) // post exists: open category path
+ ]); ////////////////////////////////////////////////////
+ ?>
+
+ </div>
+
+ <div class="col-xl-10 col-lg-9 col-sm-8 content">
+
+ <!-- THE POST -->
+ <div class="content--wrapper post">
+
+ <!-- title -->
+ <h2>
+ <?=$post['title']?>
+ <?php if (UGMC(array(61,63), $user_id)) : ?>
+ <span>
+ <a href="/wiki/admin?action=editpost&id=<?=$post_id?>" class="btn btn-xs btn-warning">
+ <i class="fas fa-pencil-alt"></i>
+ </a>
+ </span>
+ <?php endif; ?>
+ </h2>
+
+ <!-- date -->
+ <p class="post--date">
+ Δημοσίευση: <?=Render::date_friendly($post['creation_date'])?>
+ <?php if ($post['update_date'] != $post['creation_date']) : ?>
+ — Τελευταία Ενημέρωση: <?=Render::date_friendly($post['update_date'])?>
+ <?php endif; ?>
+ </p>
+
+ <!-- intro -->
+ <?php if ($post['intro'] != "") : ?>
+ <p class="intro"><?=$post['intro']?></p>
+ <?php endif; ?>
+
+ <!-- body -->
+ <div class="article-body">
+ <?=$body_html?>
+ </div>
+
+ <?php if(isset($medias)) : ?>
+ <!-- medias / attachments -->
+ <div class="medias">
+ <h4>Συνημμένα:</h4>
+ <ol>
+ <?php foreach($medias as $file) : ?>
+ <?php if ($file->reference == 1) : ?>
+ <li>
+ <a href="<?=CDN?><?=$file->path?>"
+ data-type="<?=$file->type?>"
+ target="_blank">
+ <?=$file->title?>
+ </a>
+ </li>
+ <?php endif; ?>
+ <?php endforeach; ?>
+ </ol>
+ </div>
+ <?php endif; ?>
+
+ <?php if(isset($tags)) : ?>
+ <!-- tags -->
+ <div class="tags">
+ Επικέτες:
+ <?php foreach($tags as $tag) : ?>
+ <a href="/wiki/tag?id=<?=$tag->id?>"><?=$tag->name?></a>
+ <?php endforeach; ?>
+ </div>
+ <?php endif; ?>
+
+ </div>
+
+ </div><!-- /content (post) -->
+
+ </div>
+ </div><!-- /main-content -->
+
+
+ <!-- MODALS -->
+
+ <!-- product-modal -->
+ <div id="view-product" class="modal" tabindex="-1" >
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ <i class="glyphicon glyphicon-tag"></i> Στοιχεία Προϊόντος
+ </h4>
+ </div>
+ <div class="modal-body">
+
+ <div id="modal-loader" style="display: none; text-align: center;">
+ <img src="<?php echo RP ?>img/ajax-loader.gif">
+ </div>
+
+ <div id="dynamic-product"></div>
+
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default" data-dismiss="modal">Κλείσιμο</button>
+ </div>
+
+ </div>
+ </div>
+ </div><!-- /product-modal -->
+
+ <!-- store-modal -->
+ <div id="view-store" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="store-title" aria-hidden="true" style="display: none; z-index: 2000;">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ <i class="glyphicon glyphicon-shopping-cart"></i> Πληροφορίες Καταστήματος
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div id="mapCanvas" style="height: 300px"></div>
+ <div id="modal-loader-store" style="display: none; text-align: center;">
+ <img src="<?php echo RP ?>img/ajax-loader.gif">
+ </div>
+ <div id="dynamic-content"></div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default" data-dismiss="modal">Κλείσιμο</button>
+ </div>
+ </div>
+ </div>
+ </div><!-- /store-modal -->
+
+ <script src="/wiki/js/wiki.js"></script>
+ <script src="/wiki/js/post-features.js"></script>
+</body>
+</html>
diff --git a/public/app/views/templates/course.php b/public/app/views/templates/course.php
new file mode 100644
index 0000000..1d212b8
--- /dev/null
+++ b/public/app/views/templates/course.php
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - <?=$title?></title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes');
+ ?>
+
+</head>
+<body class="classroom">
+
+ <?php // top-bar
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/top_bar', [
+ 'id' => $id,
+ 'label' => $breadcrumbs[ $id ]['rec']['label'],
+ 'parents' => $breadcrumbs[ $id ]['parents']
+ ]);
+ ?>
+
+
+<div class="main-content container">
+ <div class="row">
+
+ <div class="col col-md-5 col-lg-4 d-sm-none d-md-block side-bar">
+
+ <?php // tree of courses
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/courses_menu', [
+ 'categories' => $categories,
+ 'open_path' => $course_path
+ ]);
+ ?>
+
+ </div>
+
+ <div class="col col-sm-12 col-md-7 col-lg-8 course-view">
+
+ <!-- Category Title -->
+ <h3>
+ <?=$breadcrumbs[ $id ]['rec']['label']?>
+ </h3>
+
+
+ <!-- Sub-Categories -->
+ <?php
+ Render::view('components/subcategories',[
+ 'id' => $id,
+ 'breadcrumbs' => $breadcrumbs
+ ])
+ ?>
+
+
+ <!-- Articles List -->
+ <?php if ($lessons) : ?>
+
+ <h5>Μαθήματα</h5>
+
+ <?php // list of posts/articles
+ ////////////////////////////////////////////////////
+ Render::view("components/lessons_list", [
+ 'lessons' => $lessons,
+ 'fieldset' => ['intro', 'level'] // ['intro', 'date']
+ ]);
+ ?>
+ <?php endif; ?>
+
+
+
+
+ </div>
+
+ </div>
+ </div>
+
+ <footer>
+ <div class="container">
+
+ <?php // render footer
+ ////////////////////////////////////////////////////////////////
+ Render::view('components/footer', [ 'entity' => $entity ]);
+ ?>
+
+ </div>
+ </footer>
+
+
+ <script src="/assets/js/classroom.js"></script>
+</body>
+</html>
diff --git a/public/app/views/templates/lesson.php b/public/app/views/templates/lesson.php
new file mode 100644
index 0000000..0867cd3
--- /dev/null
+++ b/public/app/views/templates/lesson.php
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - <?=$title?></title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes');
+ ?>
+
+</head>
+<body class="classroom">
+
+ <?php // top-bar
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/top_bar', [
+ 'id' => $id,
+ 'label' => $breadcrumbs[ $course_id ]['rec']['label'],
+ 'parents' => $breadcrumbs[ $course_id ]['parents']
+ ]);
+ ?>
+
+
+<div class="main-content container">
+ <div class="row">
+
+ <div class="col col-md-5 col-lg-4 d-sm-none d-md-block side-bar">
+
+ <?php // tree of courses
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/courses_menu', [
+ 'categories' => $categories,
+ 'open_path' => $course_path
+ ]);
+ ?>
+
+ </div>
+
+ <div class="col col-sm-12 col-md-7 col-lg-8 lesson-view">
+
+ <!-- Lesson Title -->
+ <h3>
+ <?=$title?>
+ </h3>
+
+ <!-- intro -->
+ <?php if ($lesson['intro'] != "") : ?>
+ <p class="intro"><?=$lesson['intro']?></p>
+ <?php endif; ?>
+
+ <!-- body -->
+ <div class="article-body">
+ <?php
+
+ $parsedown = new Parsedown();
+ echo $parsedown->text($lesson['body']);
+
+ ?>
+ </div>
+
+
+ </div>
+
+ </div>
+ </div>
+
+ <footer>
+ <div class="container">
+
+ <?php // render footer
+ ////////////////////////////////////////////////////////////////
+ Render::view('components/footer', [ 'entity' => $entity ]);
+ ?>
+
+ </div>
+ </footer>
+
+ <script src="/assets/js/classroom.js"></script>
+ <script src="/assets/js/lesson-features.js"></script>
+</body>
+</html>
diff --git a/public/app/views/templates/page.php b/public/app/views/templates/page.php
new file mode 100644
index 0000000..6afe45e
--- /dev/null
+++ b/public/app/views/templates/page.php
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - <?=$page['title']?></title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes');
+ ?>
+
+</head>
+<body class="classroom">
+
+ <?php // top-bar
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/top_bar', [
+ 'id' => 0,
+ 'label' => '',
+ 'parents' => []
+ ]);
+ ?>
+
+
+ <div class="main-content container page-container">
+
+
+ <div class="lesson-view">
+
+ <!-- page Title -->
+ <h3>
+ <?=$page['title']?>
+ </h3>
+
+ <!-- page body -->
+ <div class="article-body">
+ <?php
+
+ $parsedown = new Parsedown();
+ echo $parsedown->text($page['body']);
+
+ ?>
+ </div>
+
+
+ </div>
+
+ </div>
+
+ <footer>
+ <div class="container">
+
+ <?php // render footer
+ ////////////////////////////////////////////////////////////////
+ Render::view('components/footer', [ 'entity' => $entity ]);
+ ?>
+
+ </div>
+ </footer>
+
+ <script src="/assets/js/classroom.js"></script>
+ <script src="/assets/js/lesson-features.js"></script>
+</body>
+</html>
diff --git a/public/app/views/user/login.php b/public/app/views/user/login.php
new file mode 100644
index 0000000..1a59d43
--- /dev/null
+++ b/public/app/views/user/login.php
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - Είσοδος Χρήστη</title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes');
+ ?>
+
+ <style>
+ html,body{
+ padding:0; margin:0;
+ font-size:1.25em;
+ color: #037; background: #eee;
+ line-height:1.2em;
+ }
+ h3 {
+ font-size: 2em; font-style: normal;
+ font-weight: 400;
+ color: #05a;
+ margin: 0; padding: 5px;
+ opacity: 0.27;
+ border-bottom: 1px solid #05a;
+ }
+ .container { display: flex; height: 100vh; align-items:center; }
+ .content { position: relative;
+ max-width:480px; width: 100%;
+ margin: auto auto; padding: 1.5em;
+ justify-center: center;
+ background: #ddd;
+ border-radius: 8px;
+ box-shadow: 1px 5px 7px #7777;
+ }
+ </style>
+</head>
+<body class="classroom">
+ <div class='container'>
+ <div class='content'>
+ <h4>Είσοδος Χρήστη</h4>
+ <hr />
+ <form method="post">
+ <div class="mb-3">
+ <label for="exampleInputEmail1" class="form-label">Email</label>
+ <input type="email" class="form-control form-control-sm" name="email" aria-describedby="emailHelp" required>
+ </div>
+ <div class="mb-3">
+ <label for="exampleInputPassword1" class="form-label">Κωδικός Πρόσβασης</label>
+ <input type="password" class="form-control form-control-sm" name="password" required>
+ </div>
+ <br />
+ <button type="submit" class="btn btn-primary btn-sm col-12">Επιβεβαίωση και Είσοδος</button>
+ </form>
+ </div>
+ </div>
+</body>
+
+<script>
+ // When form is submited
+ // -------------------------------------------------------------------------
+
+ $('.content form').submit( event => {
+ event.preventDefault();
+
+
+ // prepare data to POST
+ var data = {
+ email: $('input[name=email]').val(),
+ password: $('input[name=password]').val()
+ };
+
+ // select action url (add or update)
+ var request = '/account/check-login';
+
+ // send POST request
+ $.post(request, data)
+ .done(function( data ) {
+ if (data.status !== false) {
+ window.location.href = data.status.goto;
+
+ } else {
+ alert('Wrong credentials; please try again');
+ }
+ });
+
+
+ });
+</script>
+
+</html>
diff --git a/public/app/views/user/new-password.php b/public/app/views/user/new-password.php
new file mode 100644
index 0000000..06e7fac
--- /dev/null
+++ b/public/app/views/user/new-password.php
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - Επαναφορά κωδικού πρόσβασης</title>
+
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@400&display=swap" rel="stylesheet">
+
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-aFq/bzH65dt+w6FI2ooMVUpc+21e0SRygnTpmBvdBgSdnuTN7QbdgL+OapgHtvPp" crossorigin="anonymous">
+
+ <link href="/assets/css/overides.css" rel="stylesheet">
+
+ <style>
+ html,body{
+ padding:0; margin:0;
+ font-size:1.25em;
+ color: #037; background: #eee;
+ line-height:1.2em;
+ }
+ h3 {
+ font-size: 2em; font-style: normal;
+ font-weight: 400;
+ color: #05a;
+ margin: 0; padding: 5px;
+ opacity: 0.27;
+ border-bottom: 1px solid #05a;
+ }
+ .container { display: flex; height: 100vh; align-items:center; }
+ .content { position: relative;
+ max-width:480px; width: 100%;
+ margin: auto auto; padding: 1.5em;
+ justify-center: center;
+ background: #ddd;
+ border-radius: 8px;
+ box-shadow: 1px 5px 7px #7777;
+ }
+ </style>
+</head>
+<body class="classroom">
+ <div class='container'>
+ <div class='content'>
+ <h4>Επαναφορά κωδικού πρόσβασης</h4>
+ <hr />
+ <form method="post" action="/login-check">
+ <div class="mb-3">
+ <label for="name" class="form-label">Κωδικός μίας χρήσης</label>
+ <input type="text" class="form-control form-control-sm" name="name" required>
+ </div>
+ <div class="mb-3">
+ <label for="exampleInputPassword1" class="form-label">Νέος κωδικός πρόσβασης</label>
+ <input type="password" class="form-control form-control-sm" name="password" required>
+ </div>
+ <div class="mb-3">
+ <label for="exampleInputPassword2" class="form-label">Επιβεβαίωση κωδικού πρόσβασης</label>
+ <input type="password" class="form-control form-control-sm" name="password2" required>
+ </div>
+ <br />
+ <button type="submit" class="btn btn-primary btn-sm col-12">Επιβεβαίωση και Είσοδος</button>
+ </form>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/public/app/views/user/registration.php b/public/app/views/user/registration.php
new file mode 100644
index 0000000..f0a110c
--- /dev/null
+++ b/public/app/views/user/registration.php
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - Εγγραφή</title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes');
+ ?>
+
+ <style>
+ html,body{
+ padding:0; margin:0;
+ font-size:1.25em;
+ color: #037; background: #eee;
+ line-height:1.2em;
+ }
+ h3 {
+ font-size: 2em; font-style: normal;
+ font-weight: 400;
+ color: #05a;
+ margin: 0; padding: 5px;
+ opacity: 0.27;
+ border-bottom: 1px solid #05a;
+ }
+ .container { display: flex; height: 100vh; align-items:center; }
+ .content { position: relative;
+ max-width:480px; width: 100%;
+ margin: auto auto; padding: .5em;
+ justify-center: center;
+ font-size: .9em;
+ }
+ .content-form {
+ padding: 1.5em;
+ background: #ddd;
+ border-radius: 8px;
+ box-shadow: 1px 5px 7px #7777;
+ }
+ .content .info { padding: 1.5em; font-size: .92em; }
+ .content .info p { padding: .5em; }
+ .form-label { margin-bottom: .25rem; }
+ </style>
+</head>
+<body class="classroom">
+ <div class='container'>
+ <div class="content">
+ <div class="info">
+ <h4>Classroom: Εγγραφή</h4>
+ </div>
+ <div class='content-form'>
+ <form method="post">
+ <div>
+ <div class="mb-3">
+ <label for="name" class="form-label">Όνομα</label>
+ <input type="text" class="form-control form-control-sm" name="name" required>
+ </div>
+ <div class="mb-3">
+ <label for="surname" class="form-label">Επίθετο</label>
+ <input type="text" class="form-control form-control-sm" name="surname" required>
+ </div>
+ <div class="mb-3">
+ <label for="exampleInputEmail1" class="form-label">Email</label>
+ <input type="email" class="form-control form-control-sm" name="email" aria-describedby="emailHelp" rerquired>
+ </div>
+ <div class="mb-3">
+ <label for="exampleInputPassword1" class="form-label">Κωδικός Πρόσβασης</label>
+ <input type="password" class="form-control form-control-sm" name="password" required>
+ </div>
+ <div class="mb-3">
+ <label for="exampleInputPassword2" class="form-label">Επιβεβαίωση Κωδικού Πρόσβασης</label>
+ <input type="password" class="form-control form-control-sm" name="password2" required>
+ </div>
+ <br />
+ <button type="submit" class="btn btn-primary btn-sm col-12">Εγγραφή</button>
+ </div>
+ </form>
+ </div>
+ <div class="info">
+ <p>Αν έχετε ήδη λογαρισμό <a href="/login">συνδεθείτε</a>.</p>
+ <p>Σε κάθε περίπτωση μπορείτε να επιστρέψετε στην <a href="/">αρχική σελίδα</a>.</p>
+ </div>
+ </div>
+ </div>
+</body>
+
+<script>
+ // When form is submited
+ // -------------------------------------------------------------------------
+
+ $('.content-form form').submit( event => {
+ event.preventDefault();
+
+ if ($('input[name=password]').val() != $('input[name=password2]').val()) {
+ alert('Τα δυο passrods δεν ταιριάζουν!');
+
+ } else {
+
+ // prepare data to POST
+ var data = {
+ name: $('input[name=name]').val(),
+ surname: $('input[name=surname]').val(),
+ email: $('input[name=email]').val(),
+ password: $('input[name=password]').val()
+ };
+
+ // select action url (add or update)
+ var request = '/account/register';
+
+ // send POST request
+ $.post(request, data)
+ .done(function( data ) {
+ $('.classroom .content-form').html(data.message)
+ });
+ }
+
+ });
+</script>
+
+</html>
diff --git a/public/app/views/welcome.php b/public/app/views/welcome.php
new file mode 100644
index 0000000..c5552a8
--- /dev/null
+++ b/public/app/views/welcome.php
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><?=SITE_TITLE?> - Welcome!</title>
+
+ <?php // header includes
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/header_includes');
+ ?>
+
+</head>
+<body class='classroom'>
+
+ <?php // top-bar
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/top_bar');
+ ?>
+
+
+ <div class="main-content container">
+ <div class="row">
+
+ <div class="col col-md-5 col-lg-4 d-sm-none d-md-block side-bar">
+
+
+ <?php // tree of courses
+ ////////////////////////////////////////////////////////////////////////
+ Render::view('components/courses_menu', [
+ 'categories' => $categories,
+ 'open_path' => []
+ ]);
+ ?>
+ </div>
+
+ <div class="col col-sm-12 col-md-7 col-lg-8">
+ the content goes here
+ </div>
+
+ </div>
+ </div>
+
+
+ <!-- TESTING STRINGS (while developing only) -->
+ <?php /* --- print_r($_SESSION); ?>
+ <?php print_r($_COOKIE); ?>
+ <?php
+ if (isset($_SESSION)) echo '; session seted ; ';
+ ?>
+ <?=
+ isset($_SESSION[UserTokenInterface::DEFAULT_PREFIX_KEY])
+ ? var_dump($_SESSION[UserTokenInterface::DEFAULT_PREFIX_KEY])
+ : 'none!'
+ --- */ ?>
+
+
+ <footer>
+ <div class="container">
+
+ <?php // render footer
+ ////////////////////////////////////////////////////////////////
+ Render::view('components/footer', [ 'entity' => $entity ]);
+ ?>
+
+ </div>
+ </footer>
+
+ <script src="/assets/js/classroom.js"></script>
+
+</body>
+</html> \ No newline at end of file