--- Folder Structure --- tp_servicedesk/ LICENSE README.md composer.json package.json app/ debug.php config/ routes.ini controllers/ AttachmentController.php AuthController.php BaseController.php CommentController.php DashboardController.php HomeController.php KBController.php ParsedownPreview.php ProjectController.php TagController.php ThemeController.php TicketController.php UserController.php Admin/ HomeController.php TicketOptionsController.php UserController.php extensions/ BulmaFormHelper.php CSRFHelper.php IconsHelper.php ParsedownHelper.php ParsedownTableExtension.php interfaces/ CRUD.php models/ Attachment.php Comment.php Tag.php Ticket.php TicketPriority.php TicketStatus.php traits/ CheckCSRF.php RequiresAuth.php ui/ issues.md modal/ partials/ ticket_item.html parts/ clipboard.html session/ error.html templates/ layout.html views/ dashboard.html home.html login.html admin/ index.html priorities/ create.html index.html attachment/ index.html comments/ view.html kb/ create.html edit.html index.html view.html project/ create.html edit.html index.html view.html tag/ create.html index.html ticket/ create.html create.html.v1 edit.html edit.html.v1 index.html index_row.html view.html user/ edit.html index.html downloads/ lib/ public/ index.php logo.svg style.css test.md.php css/ js/ kb_edit.js markdown_preview.js ticket_view.js tp_md_editor.js tmp/ scss/ main.scss components/ _ticket-item.scss vendor/ _bulma-tools.scss _bulma.scss storage/ ============================================================ --- File Contents --- --- File: LICENSE --- MIT License Copyright (c) 2025 tp_dhu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- End File: LICENSE --- --- File: README.md --- # Coding Approach - Classes - Class names should be in `CapitalCamelCase` - Class functions will be in `camelCase` - Functions - Function variables will be in `snake_case` - Arrays - Array keys will be in `snake_case` - SQL - table names, and columns names will be in `snake_case` - Don't repeat yourself (DRY) - Each fucntion should have a single purpose # tp_servicedesk A { service desk, ticket, knowledge base } web application written in PHP using fat free framework. Used to keep track of ongoing projects/tasks, allow to view and search historic projects which may have already answered a previous question. Knowledge Base built from applications using markdown. ## Plesk - quest notes - plesk ext composer --application -register -domain desk.tinylink.uk -path desk.tinylink.uk/tp_servicedesk ~ https://www.plesk.com/kb/support/how-to-change-in-the-php-composer-extension-the-path-of-the-composer-json-file/ ## Milestones - Database created locally - .gitignore added - added AuthController - login and logout process --- End File: README.md --- --- File: composer.json --- { "name": "tp/tp_servicedesk", "description": "", "config": { "vendor-dir": "lib" }, "require": { "bcosca/fatfree-core": "^3.9", "erusev/parsedown": "^1.7", "ezyang/htmlpurifier": "^4.18", "erusev/parsedown-extra": "^0.8.1", "singular-it/parsedown-checkbox": "^0.3.5" } } --- End File: composer.json --- --- File: package.json --- { "dependencies": { "bulma": "^1.0.3" }, "scripts": { "sass": "sass scss/main.scss public/css/main.css", "sass:min": "sass scss/main.scss public/css/main.min.css --style compressed", "sass:watch": "sass --watch scss/main.scss:public/css/main.css" } } --- End File: package.json --- --- File: app/debug.php --- %s', print_r($obj, 1)); } function debug_var_dump($obj) { printf('
%s
', var_dump_str($obj)); } function var_dump_str() { $argc = func_num_args(); $argv = func_get_args(); if ($argc > 0) { ob_start(); call_user_func_array('var_dump', $argv); $result = ob_get_contents(); ob_end_clean(); return $result; } return ''; } --- End File: app/debug.php --- --- File: app/config/routes.ini --- [routes] ; home GET /=HomeController->display ; auth GET /login=AuthController->showLoginForm POST /login=AuthController->login GET /logout=AuthController->logout ; tickets - CRUD (CREATE, READ, UPDATE, DELETE) GET /tickets=TicketController->index GET /ticket/@id=TicketController->view GET /ticket/create=TicketController->createForm POST /ticket/create=TicketController->create GET /ticket/@id/edit=TicketController->editForm POST /ticket/@id/update=TicketController->update GET /ticket/@id/delete=TicketController->delete ; additional routes - comments POST /ticket/@id/comment=CommentController->create GET /ticket/@id/comment/@comment_id/delete=CommentController->delete GET /ticket/@id/comments=CommentController->index ; route for linking a child to a parent POST /ticket/@id/add-subtask=TicketController->addSubtask ; attachments GET /ticket/@id/attachments=AttachmentController->index POST /ticket/@id/attachments/upload=AttachmentController->upload GET /attachment/@id/download=AttachmentController->download GET /attachment/@id/delete=AttachmentController->delete GET /attachment/@id/view=AttachmentController->view ; knowledgebase GET /kb=KBController->index GET /kb/@id=KBController->view GET /kb/create=KBController->createForm POST /kb/create=KBController->create GET /kb/@id/edit=KBController->editForm POST /kb/@id/update=KBController->update ; tags GET /tags=TagController->index GET /tag/create=TagController->createForm POST /tag/create=TagController->create ; parsedown preview POST /parsedown/preview=ParsedownPreview->view ; toggle-theme POST /toggle-theme = ThemeController->toggle ; dashboard GET /dashboard=DashboardController->index ; projects GET /projects=ProjectController->index GET /project/@id=ProjectController->view GET /project/create=ProjectController->createForm POST /project/create=ProjectController->create GET /project/@id/edit=ProjectController->editForm POST /project/@id/update=ProjectController->update ; additional routes - user GET /users=UserController->index GET /user/@id/edit=UserController->editForm POST /user/@id/update=UserController->update ; admin GET /admin=Admin\HomeController->index ; admin/priority GET /admin/priority=Admin\TicketOptionsController->listPriorities GET /admin/priority/create=Admin\TicketOptionsController->createPriorityForm POST /admin/priority/create=Admin\TicketOptionsController->createPriority GET /admin/priority/@id/edit=Admin\TicketController->editPriorityForm POST /admin/priority/@id/update=Admin\TicketController->updatePriority GET /admin/priority/@id/delete=Admin\TicketController->deletePriority ; admin/status GET /admin/status=Admin\TicketOptionsController->listStatuses GET /admin/status/create=Admin\TicketOptionsController->createStatusForm POST /admin/status/create=Admin\TicketOptionsController->createStatus GET /admin/status/@id/edit=Admin\TicketController->editStatusForm POST /admin/status/@id/update=Admin\TicketController->updateStatus GET /admin/status/@id/delete=Admin\TicketController->deleteStatus --- End File: app/config/routes.ini --- --- File: app/controllers/AttachmentController.php --- check_access($f3); $ticket_id = (int) $f3->get('PARAMS.id'); $db = $f3->get('DB'); // fetch attachments $attachments = $db->exec( 'SELECT a.*, u.username FROM attachments a LEFT JOIN users u ON u.id = a.uploaded_by WHERE a.ticket_id = ? ORDER BY a.created_at DESC', [$ticket_id] ); $f3->set('ticket_id', $ticket_id); $f3->set('attachments', $attachments); $f3->set('content', 'views/attachment/index.html'); // echo \Template::instance()->render('templates/layout.html'); echo \Template::instance()->render($f3->get('content')); } // handle file upload public function upload($f3){ $this->check_access($f3); $this->checkCSRF($f3, '/ticket/'.$f3->get('PARAMS.id')); // not ideal for AJAX $ticket_id = (int) $f3->get('PARAMS.id'); $uploaded_by = $f3->get('SESSION.user.id'); if(!isset($_FILES['attachment']) || $_FILES['attachment']['error'] !== UPLOAD_ERR_OK){ $f3->reroute('/ticket/'.$ticket_id.'/attachments'); } $file_info = $_FILES['attachment']; $original_name = $file_info['name']; $tmp_path = $file_info['tmp_name']; // create a unique file path $upload_dir = '../storage/attachments/tickets/'.$ticket_id.'/'; if(!is_dir($upload_dir)){ mkdir($upload_dir, 0777, true); } // if file exists increment version $db = $f3->get('DB'); $existing = $db->exec( 'SELECT * FROM attachments WHERE ticket_id =? AND file_name = ? ORDER BY version_number DESC LIMIT 1', [$ticket_id, $original_name] ); $new_version = 1; if($existing){ $new_version = $existing[0]['version_number'] + 1; } $final_path = $upload_dir.$new_version.'_'.$original_name; // move file move_uploaded_file($tmp_path, $final_path); // store meta data in DB $db->exec( 'INSERT INTO attachments (ticket_id, path, file_name, version_number, uploaded_by, created_at) VALUES (?,?,?,?,?,NOW())', [$ticket_id, $final_path, $original_name, $new_version, $uploaded_by] ); $f3->reroute('/ticket/'.$ticket_id.''); // ideal ajax response: // $f3->json(['success' => true, 'message' => 'file upload success.', 'filename' => $original_name, 'version' => $new_version]); } // download attachment public function download($f3){ $this->check_access($f3); $attachment_id = (int) $f3->get('PARAMS.id'); $db = $f3->get('DB'); $rows = $db->exec('SELECT * FROM attachments WHERE id = ?', [$attachment_id]); if(!$rows){ $f3->error(404, "File not found"); return; } $attachment = $rows[0]; $file_path = $attachment['path']; $file_name = $attachment['file_name']; // validate file exists if(!file_exists($file_path)){ $f3->error(404, "File not found"); return; } // output headers for download header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="'.basename($file_name).'"'); header('Content-Length: '. filesize($file_path)); // flush headers flush(); // read file readfile($file_path); exit; } // delete an attachment public function delete($f3){ $this->check_access($f3); $attachment_id = (int) $f3->get('PARAMS.id'); $current_user = $f3->get('SESSION.user'); $db = $f3->get('DB'); $rows = $db->exec('SELECT * FROM attachments WHERE id =? LIMIT 1', [$attachment_id]); if(!$rows){ $f3->error(404, "Attachment not found"); return; } $attachment = $rows[0]; // TODO: role or ownership if(file_exists($attachment['path'])){ unlink($attachment['path']); } // remove DB row $db->exec('DELETE FROM attachments WHERE id =?', [$attachment_id]); } // view attachment public function view($f3){ $this->check_access($f3); $attachment_id = (int) $f3->get('PARAMS.id'); $db = $f3->get('DB'); $rows = $db->exec('SELECt * FROM attachments WHERE id = ?', [$attachment_id]); if(!$rows){ $f3->error(404, "File not found"); return; } $attachment = $rows[0]; $file_path = $attachment['path']; $file_name = $attachment['file_name']; if(!file_exists($file_path)){ $f3->error(404, "File not found"); return; } // detect mime type $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $file_path); finfo_close($finfo); header('Content-Type: ' . $mime_type); header('Content-Disposition: inline; filename="' . basename($file_name) . '"'); header('Content-Length: ' . filesize($file_path)); flush(); readfile($file_path); exit; } } --- End File: app/controllers/AttachmentController.php --- --- File: app/controllers/AuthController.php --- set('error', $f3->get('SESSION.login_error')); $f3->clear('SESSION.login_error'); // this can be in our controller base $f3->set('content', 'views/login.html'); echo \Template::instance()->render('templates/layout.html'); $f3->clear('error'); } public function login($f3){ // CSRF $this->checkCSRF($f3, '/login'); $username = $f3->get('POST.username'); $password = $f3->get('POST.password'); $db = $f3->get('DB'); // query for user $result = $db->exec( 'SELECT u.id, u.username, u.password, u.role, u.is_admin, r.role as role_name FROM users u LEFT JOIN roles r ON r.id = u.role WHERE username =? LIMIT 1', $username ); // verifiy password if($result){ $user = $result[0]; // first row if(password_verify($password, $user['password'])){ // valid $f3->set('SESSION.user', [ 'id'=> $user['id'], 'username' => $user['username'], 'role' => $user['role'], 'role_name' => $user['role_name'], 'is_admin' => $user['is_admin'] ]); if($f3->exists('SESSION.redirect')){ $redirect = $f3->get('SESSION.redirect'); $f3->clear('SESSION.redirect'); $f3->reroute($redirect); } $f3->reroute('/dashboard'); } else { $f3->set('SESSION.login_error', 'Invalid password'); } } else { // if here, login failed. $f3->set('SESSION.login_error', 'Invalid username'); } $f3->reroute('/login'); } public function logout($f3){ $f3->clear('SESSION'); $f3->reroute('/'); } } --- End File: app/controllers/AuthController.php --- --- File: app/controllers/BaseController.php --- f3 = \Base::instance(); } // helper function protected function getDB() { return $this->f3->get('DB'); } /** * Enforce that the user is logged in before proceeding. */ protected function requireLogin() { // using trait $this->check_access($this->f3); return; // abstract if(!$this->f3->exists('SESSION.user')){ $this->f3->set('SESSION.redirect', $this->f3->get('PATH')); $this->f3->reroute('/login'); } } /** * Enforce that the user is logged in AND is an admin before proceeding. */ protected function requireAdmin() { $this->requireLogin(); // First, ensure the user is logged in // Check if the user is an admin (assuming 'is_admin' property in session) if (!$this->f3->get('SESSION.user.is_admin')) { // Optionally set an error message $this->f3->set('SESSION.error', 'Admin access required.'); $this->f3->reroute('/'); // Redirect non-admins to home page } } /** * Set up a main layout template and inject the specified view path * optional $data to pass variables down to template */ protected function renderView(string $viewPath, array $data = []):void { foreach($data as $key => $value){ $this->f3->set($key, $value); } // set {{content}} $this->f3->set('content', $viewPath); // render tempalte echo \Template::instance()->render('templates/layout.html'); // clear SESSION.error $this->f3->clear('SESSION.error'); } } --- End File: app/controllers/BaseController.php --- --- File: app/controllers/CommentController.php --- exists('SESSION.user')){ $f3->reroute('/login'); } $this->checkCSRF($f3, '/ticket/' . $f3->get('PARAMS.id')); $ticket_id = (int) $f3->get('PARAMS.id'); $comment_text = $f3->get('POST.comment'); $current_user_id = $f3->get('SESSION.user.id'); if(empty($comment_text)){ $f3->set('SESSION.error', 'ticket not updated. No content'); $f3->reroute('/ticket/' . $ticket_id); } // insert comment $db = $f3->get('DB'); $db->exec( 'INSERT INTO ticket_comments (ticket_id, comment, created_by, created_at) VALUES (?, ?, ?, NOW())', [$ticket_id, $comment_text, $current_user_id] ); $f3->reroute('/ticket/' . $ticket_id); } /** * Delete an existing comment * Route: GET /tickey/@id/comment/@comment_id/delete */ public function delete($f3){ if(!$f3->exists('SESSION.user')){ $f3->reroute('/login'); } $ticket_id = (int) $f3->get('PARAMS.id'); $comment_id = (int) $f3->get('PARAMS.comment_id'); $current_user = $f3->get('SESSION.user'); $db = $f3->get('DB'); //optional: check if user is allowed to delete comment. // fetch who created the comment $comment_row = $db->exec( 'SELECT created_by FROM ticket_comments WHERE id = ? AND ticket_id = ? LIMIT 1', [$comment_id, $ticket_id] ); if(!$comment_row){ $f3->set('SESSION.error', 'Error: Ticket comment ID not found.'); $f3->reroute('/ticket/'.$ticket_id); } $comment_owner = $comment_row[0]['created_by']; // TODO: $is_admin = () if($current_user['id'] !== $comment_owner){ // no permission $f3->set('SESSION.error', 'You do not have permission to delete this ticket'); $f3->reroute('/ticket/'. $ticket_id); } // Delete - addition, rather than delete, we set a delete flag $db->exec('UPDATE ticket_comments SET deleted = 1 WHERE id = ?', [$comment_id]); $f3->reroute('/ticket/' . $ticket_id); } // view comments public function index($f3){ $ticket_id = (int) $f3->get('PARAMS.id'); $db = $f3->get('DB'); $results = $db->exec(' SELECT c.*, u.username AS author_name FROM ticket_comments c LEFT JOIN users u ON c.created_by = u.id WHERE c.ticket_id = ? ORDER BY c.created_at DESC', [$ticket_id] ); $comments = $results; $f3->set('comments', $comments); echo \Template::instance()->render('views/comments/view.html'); } } --- End File: app/controllers/CommentController.php --- --- File: app/controllers/DashboardController.php --- requireLogin(); $this->renderView('views/dashboard.html'); } } --- End File: app/controllers/DashboardController.php --- --- File: app/controllers/HomeController.php --- renderView('views/home.html'); } // ... } --- End File: app/controllers/HomeController.php --- --- File: app/controllers/KBController.php --- check_access($f3); $db = $f3->get('DB'); $search_term = $f3->get('GET.search'); $tag_param = $f3->get('GET.tag'); // base query $sql = 'SELECT a.* FROM kb a'; $args = []; if($tag_param){ $sql .= ' JOIN kb_tags AS at ON a.id = at.article_id JOIN tags t ON at.tag_id = t.id WHERE t.name = ? '; $args[] = $tag_param; if($search_term){ $sql .= ' AND LOWER(a.title) LIKE LOWER(?)'; $args[] = '%' . $search_term . '%'; } } else if ($search_term){ $sql .= ' WHERE LOWER(a.title) LIKE LOWER(?)'; $args[] = '%' . $search_term . '%'; } $sql .= ' ORDER BY a.created_at DESC'; $articles = $db->exec($sql, $args); // render $f3->set('articles', $articles); $f3->set('content', 'views/kb/index.html'); echo \Template::instance()->render('templates/layout.html'); $f3->clear('SESSION.error'); } /** * Form to create new article */ public function createForm($f3){ $this->check_access($f3); $db = $f3->get('DB'); $all_tags = $db->exec('SELECT * FROM tags ORDER BY name ASC'); $f3->set('all_tags', $all_tags); // render $f3->set('content', 'views/kb/create.html'); echo \Template::instance()->render('templates/layout.html'); $f3->clear('SESSION.error'); } // handle POST public function create($f3){ $this->check_access($f3); $this->checkCSRF($f3, '/kb/create'); $title = $f3->get('POST.title'); $content = $f3->get('POST.content'); $created_by = $f3->get('SESSION.user.id'); $db = $f3->get('DB'); // insert $db->exec( 'INSERT INTO kb (title, content, created_by, updated_by, created_at, updated_at) VALUES (?,?,?,?, NOW(), NOW())', [$title, $content, $created_by, $created_by] ); $article_id = $db->lastInsertId(); // TODO: tags $f3->reroute('/kb'); } // protected function check_kb_exists($article_id, $db, $f3){ $articles = $db->exec( 'SELECT * FROM kb WHERE id = ? LIMIT 1', [$article_id] ); if(!$articles){ $f3->set('SESSION.error', 'Article not found'); $f3->reroute('/kb'); } return $articles; } // view a single public function view($f3){ $this->check_access($f3); $article_id = $f3->get('PARAMS.id'); $db = $f3->get('DB'); $articles = $this->check_kb_exists($article_id, $db, $f3); $article = $articles[0]; $f3->set('article', $article); // TODO: tags $tags = $db->exec( 'SELECT t.* FROM tags AS t JOIN kb_tags AS at ON t.id = at.tag_id WHERE at.kb_id = ?', [$article_id] ); // render $f3->set('content', 'views/kb/view.html'); echo \Template::instance()->render('templates/layout.html'); $f3->clear('SESSION.error'); } /** * Form to edit existing kb article */ public function editForm($f3){ $this->check_access($f3); $article_id = $f3->get('PARAMS.id'); $db = $f3->get('DB'); $articles = $this->check_kb_exists($article_id, $db, $f3); $article = $articles[0]; $f3->set('article', $article); // fetch current tags $current_tag_ids = $db->exec( 'SELECT tag_id FROM kb_tags WHERE kb_id = ?', [$article_id] ); $article_tag_ids = array_column($current_tag_ids, 'tag_id'); $f3->set('article_tag_ids', $article_tag_ids); // render $f3->set('js', 'kb_edit.js'); $f3->set('content', 'views/kb/edit.html'); echo \Template::instance()->render('templates/layout.html'); $f3->clear('SESSION.error'); } /** * Handle POST to edit existing article */ public function update($f3){ $this->check_access($f3); $this->checkCSRF($f3, '/kb/' . $f3->get('PARAMS.id') . '/edit'); $article_id = $f3->get('PARAMS.id'); $db = $f3->get('DB'); $articles = $this->check_kb_exists($article_id, $db, $f3); $article = $articles[0]; $title = $f3->get('POST.title'); $content = $f3->get('POST.content'); $updated_by = $f3->get('SESSION.user.id'); $db->exec( 'UPDATE kb SET title=?, content=?, updated_by =?, updated_at = NOW() WHERE id = ?', [$title, $content, $updated_by, $article_id] ); // update tags - first delete $db->exec('DELETE FROM kb_tags WHERE kb_id = ?', [$article_id]); $tags_id = $f3->get('POST.tags'); if(!empty($tags_id) && is_array($tags_id)){ foreach($tags_id as $tag_id){ $db->exec( 'INSERT IGNORE INTO kb_tags (article_id, tag_id) VALUES (?,?)', [$article_id, $tag_id] ); } } $f3->reroute('/kb/'.$article_id); } } --- End File: app/controllers/KBController.php --- --- File: app/controllers/ParsedownPreview.php --- get('POST.content'); echo Parsedown::instance()->text($preview_text); } } --- End File: app/controllers/ParsedownPreview.php --- --- File: app/controllers/ProjectController.php --- check_access($f3); $db = $f3->get('DB'); // retrieve projects $projects = $db->exec('SELECT * FROM projects ORDER BY created_at DESC'); $f3->set('projects', $projects); $f3->set('content', 'views/project/index.html'); echo \Template::instance()->render('templates/layout.html'); $f3->clear('SESSION.error'); } // create a new project public function createForm($f3){ $this->check_access($f3); $f3->set('content', 'views/project/create.html'); echo \Template::instance()->render('templates/layout.html'); } public function create($f3){ } // show project details including links, tickets, events, tasks public function view($f3){ $this->check_access($f3); $project_id = $f3->get('PARAMS.id'); $db = $f3->get('DB'); $result = $db->exec( 'SELECT * FROM projects WHERE id = ? LIMIT 1', [$project_id] ); $project = $result[0]; $f3->set('project', $project); $f3->set('content', 'views/project/view.html'); echo \Template::instance()->render('templates/layout.html'); } // update project details public function editForm($f3){ $this->check_access($f3); $f3->set('content', 'views/project/edit.html'); echo \Template::instance()->render('templates/layout.html'); } public function update($f3){} } --- End File: app/controllers/ProjectController.php --- --- File: app/controllers/TagController.php --- check_access($f3); $db = $f3->get('DB'); $tags = $db->exec('SELECT * FROM tags ORDER BY name ASC'); $f3->set('tags', $tags); $f3->set('content', 'views/tag/index.html'); echo \Template::instance()->render('templates/layout.html'); } public function createForm($f3){ $this->check_access($f3); $f3->set('content', 'views/tag/create.html'); echo \Template::instance()->render('templates/layout.html'); } public function create($f3){ $this->check_access($f3); $this->checkCSRF($f3, '/tag/create'); $name = $f3->get('POST.name'); $color = $f3->get('POST.color'); $db = $f3->get('DB'); // insert new tag $db->exec('INSERT IGNORE INTO tags (name, color) VALUES (?, ?)', [$name, $color]); $f3->reroute('/tags'); } public function view($f3) { } public function editForm($f3) { } public function update($f3) { } } --- End File: app/controllers/TagController.php --- --- File: app/controllers/ThemeController.php --- checkCSRF($f3, $f3->get('HEADERS.Referer') ?: '/'); $current = $f3->get('SESSION.theme') ?: 'light'; $new_theme = ($current === 'light') ? 'dark' : 'light'; $f3->set('SESSION.theme', $new_theme); $f3->reroute($f3->get('HEADERS.Referer') ?: '/'); } } --- End File: app/controllers/ThemeController.php --- --- File: app/controllers/TicketController.php --- requireLogin(); $filter = $f3->get('GET.status'); // retrieve tickets $ticket_mapper = new Ticket($this->getDB()); if($filter){ $tickets = $ticket_mapper->findFiltered($filter); } else { $tickets = $ticket_mapper->findAll(); } // render $this->renderView('views/ticket/index.html', ['tickets' => $tickets] ); $f3->clear('SESSION.error'); } // view a single ticket // TODO_PROJECTS: show a link back to the related project public function view($f3){ $this->requireLogin(); $ticket_id = $f3->get('PARAMS.id'); $ticket_mapper = new Ticket($this->getDB()); $ticket = $ticket_mapper->findById($ticket_id); if(!$ticket){ $this->f3->set('SESSION.error', 'Ticket not found'); $this->f3->reroute('/tickets'); return; } $assigned_user = $ticket->getAssignedUser(); $ticket_history = $ticket->getHistory(); $map_statuses = array_column((new TicketStatus($this->getDB()))->findAll(), 'name', 'id'); $map_priorities = array_column((new TicketStatus($this->getDB()))->findAll(), 'name', 'id'); $map_users = array_column($this->getDB()->exec('SELECT id, display_name FROM users'), 'display_name', 'id'); // render $this->renderView('views/ticket/view.html', [ 'ticket' => $ticket, 'assigned_user' => $assigned_user, 'attachments' => $ticket->attachments(), 'comments' => $ticket->comments(), 'parent_tickets' => $ticket->getParentTickets(), 'child_tickets' => $ticket->getChildTickets(), 'ticket_meta' => $ticket->getMetaAssoc(), 'ticket_history' => $ticket_history, 'map' => [ 'statuses' => $map_statuses, 'priorities' => $map_priorities, 'users' => $map_users ] ]); } // show create form // TODO_PROJECTS: dropdown to associate ticket with project public function createForm($f3){ $db = $this->getDB(); $priorities = (new TicketPriority($db))->findAll(); $statuses = (new TicketStatus($db))->findAll(); $all_tags_model = new \Tag($this->getDB()); $all_tags = $all_tags_model->find([], ['order' => 'name ASC']); // get all tags // TODO: this needs moving into a model? $users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC'); $users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users); $this->requireLogin(); $this->renderView('views/ticket/create.html',[ 'priorities' => $priorities, 'statuses' => $statuses, 'users' => $users, 'all_tags' => $all_tags ]); } // handle POST // including custom forms public function create($f3){ $this->requireLogin(); $this->checkCSRF($f3, '/ticket/create'); $data = [ 'title' => $this->f3->get('POST.title'), 'created_at' => $this->f3->get('POST.created_at'), 'description' => $this->f3->get('POST.description'), 'priority_id' => $this->f3->get('POST.priority_id'), 'status_id' => $this->f3->get('POST.status_id'), 'created_by' => $this->f3->get('SESSION.user.id'), 'assigned_to' => $this->f3->get('POST.assigned_to') == '-1' ? null : $this->f3->get('POST.assigned_to') ]; $ticket_mapper = new Ticket($this->getDB()); $new_ticket_id = $ticket_mapper->createTicket($data); // custom field // $meta_keys = $this->f3->get('POST.meta_key'); // $meta_values = $this->f3->get('POST.meta_value'); // $meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values); // $ticket_mapper->setCustomFields($meta_assoc); $new_ticket = $ticket_mapper->findById($new_ticket_id); if($new_ticket){ // TAG handling for create $posted_tags = $this->f3->get('POST.tags'); if(!empty($posted_tags) && is_array($posted_tags)){ $new_ticket->setTags($posted_tags); } } $this->f3->set('SESSION.message', 'Ticket #' . $new_ticket_id . ' created successfully.'); $this->f3->reroute('/ticket/' . $new_ticket_id); } // show edit form // including custom forms // TODO_PROJECTS: allow reasssigning or removing a project association public function editForm($f3) { $this->requireLogin(); $ticket_id = $f3->get('PARAMS.id'); $ticket_mapper = new Ticket($this->getDB()); $ticket = $ticket_mapper->findById($ticket_id); if(!$ticket){ $this->f3->set('SESSION.error', 'Ticket not found.'); $this->f3->reroute('/tickets'); } // $f3->set('js', 'markdown_preview.js'); // dropdowns $priorities = (new TicketPriority($this->getDB()))->findAll(); $statuses = (new TicketStatus($this->getDB()))->findAll(); $all_tags_model = new \Tag($this->getDB()); $all_tags = $all_tags_model->find([], ['order' => 'name ASC']); // TODO: this needs moving into a model? $users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC'); $users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users); // paradox - empty($ticket->tags) was returning true, when there were items present $current_ticket_tag_ids = []; if(count($ticket->tags) > 0){ foreach($ticket->tags as $current_tag_data){ $current_ticket_tag_ids[] = $current_tag_data['id']; } } $this->renderView('views/ticket/edit.html',[ 'ticket' => $ticket, 'ticket_meta' => $ticket->getMeta(), 'priorities' => $priorities, 'statuses' => $statuses, 'users' => $users, 'all_tags' => $all_tags, 'current_ticket_tag_ids' => $current_ticket_tag_ids ] ); return; } // process edit POST TODO: if assigned or admin public function update($f3) { $this->requireLogin(); $this->checkCSRF($f3, '/ticket/create'); $ticket_id = $f3->get('PARAMS.id'); $ticket_mapper = new Ticket($this->getDB()); $ticket = $ticket_mapper->findById($ticket_id); if(!$ticket){ $f3->set('SESSION.error', 'Ticket not found.'); $f3->reroute('/tickets'); } $data = [ 'title' => $f3->get('POST.title'), 'created_at' => $f3->get('POST.created_at'), 'description' => $f3->get('POST.description'), 'priority_id' => $f3->get('POST.priority_id'), 'status_id' => $f3->get('POST.status_id'), 'updated_by' => $f3->get('SESSION.user.id') , 'assigned_to' => $f3->get('POST.assigned_to') == -1 ? null : $f3->get('POST.assigned_to') ]; $ticket->updateTicket($data); // deal with meta data / custom fields // $meta_keys = $this->f3->get('POST.meta_key'); // $meta_values = $this->f3->get('POST.meta_value'); // $meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values); // $ticket->setCustomFields($meta_assoc); $posted_tags = $f3->get('POST.tags'); if(is_array($posted_tags)){ $ticket->setTags($posted_tags); } elseif (empty($posted_tags)){ $ticket->setTags([]); } $f3->set('SESSION.message', 'Ticket #' . $ticket_id . ' updated successfully.') ; $f3->reroute('/ticket/' . $ticket_id); } // subtask public function addSubtask($f3){ $this->requireLogin(); $this->checkCSRF($f3, '/ticket/create'); $parent_id = (int) $f3->get('PARAMS.id'); $child_id = (int) $f3->get('POST.child_ticket_id'); $ticket_mapper = new Ticket($this->getDB()); $ticket = $ticket_mapper->findById($parent_id); if(!$ticket){ $this->f3->set('SESSION.error', 'Parent Ticket not found'); $this->f3->reroute('/tickets'); } $ticket->addChildTicket($child_id); $this->f3->reroute('/ticket/' . $parent_id); } public function delete(): void { $this->requireLogin(); $ticket_id = (int)$this->f3->get('PARAMS.id'); $ticket_mapper = new Ticket($this->getDB()); $ticket = $ticket_mapper->findById($ticket_id); if(!$ticket){ $this->f3->set('SESSION.error', 'Ticket not found'); $this->f3->reroute('/tickets'); } $ticket->softDelete(); $this->f3->reroute('/tickets'); } } --- End File: app/controllers/TicketController.php --- --- File: app/controllers/UserController.php --- check_access($f3); $db = $f3->get('DB'); $users = $db->exec( 'SELECT u.*, r.role AS role_name FROM users u LEFT JOIN roles r ON r.id = u.role ORDER BY id ASC' ); $f3->set('users', $users); $f3->set('content', 'views/user/index.html'); echo \Template::instance()->render('templates/layout.html'); } public function editForm($f3){ $this->check_access($f3); $user_id = (int) $f3->get('PARAMS.id'); $db = $f3->get('DB'); $rows = $db->exec( 'SELECt * FROM users WHERE id = ? LIMIT 1', [$user_id] ); if(!$rows){ $f3->reroute('/users'); } $f3->set('edit_user', $rows[0]); $f3->set('content', 'views/user/edit.html'); echo \Template::instance()->render('templates/layout.html'); } public function update($f3){ $this->check_access($f3); $this->checkCSRF($f3, '/user/' . $f3->get('PARAMS.id') . '/edit'); $user_id = (int) $f3->get('PARAMS.id'); $new_username = $f3->get('POST.username'); // $new_role = $f3->get('POST.role_name') $db = $f3->get('DB'); $db->exec( 'UPDATE users SET username = ? WHERE id =? LIMIT 1', [$new_username, $user_id]); $f3->reroute('/users'); } public function createForm($f3) { } public function create($f3) { } public function view($f3) { } } --- End File: app/controllers/UserController.php --- --- File: app/controllers/Admin/HomeController.php --- renderView('views/admin/index.html'); } } --- End File: app/controllers/Admin/HomeController.php --- --- File: app/controllers/Admin/TicketOptionsController.php --- requireLogin(); $this->requireAdmin(); // Added admin check $model = new \TicketPriority($this->getDB()); $priorities = $model->findAll(); $this->renderView('views/admin/priorities/index.html', [ 'priorities' => $priorities ]); } public function createPriorityForm() { $this->requireLogin(); $this->requireAdmin(); // Added admin check $this->renderView('views/admin/priorities/create.html'); } public function createPriority($f3) { $this->requireLogin(); $this->requireAdmin(); // Added admin check $this->checkCSRF($f3, '/admin/priority/create'); $p = new \TicketPriority($this->getDB()); $p->name = $this->f3->get('POST.name'); $p->sort_order = $this->f3->get('POST.sort_order'); $p->save(); // Redirect after save $this->f3->reroute('/admin/priorities'); } public function editPriorityForm($f3, $params) { $this->requireLogin(); $this->requireAdmin(); $priorityId = $params['id']; $model = new \TicketPriority($this->getDB()); $priority = $model->load(['id = ?', $priorityId]); if (!$priority) { $f3->error(404, 'Priority not found'); return; } $this->renderView('views/admin/priorities/edit.html', [ 'priority' => $priority ]); } public function updatePriority($f3, $params) { $this->requireLogin(); $this->requireAdmin(); $this->checkCSRF($f3, '/admin/priority/', $params['id'] . '/edit'); $priorityId = $params['id']; $model = new \TicketPriority($this->getDB()); $priority = $model->load(['id = ?', $priorityId]); if (!$priority) { $f3->error(404, 'Priority not found'); return; } $priority->name = $this->f3->get('POST.name'); $priority->sort_order = $this->f3->get('POST.sort_order'); $priority->save(); // Redirect after update $this->f3->reroute('/admin/priorities'); } public function deletePriority($f3, $params) { $this->requireLogin(); $this->requireAdmin(); $priorityId = $params['id']; $model = new \TicketPriority($this->getDB()); $priority = $model->load(['id = ?', $priorityId]); if (!$priority) { // Optionally show an error message or just redirect $this->f3->reroute('/admin/priorities'); return; } $priority->erase(); // Redirect after delete $this->f3->reroute('/admin/priorities'); } } --- End File: app/controllers/Admin/TicketOptionsController.php --- --- File: app/controllers/Admin/UserController.php --- build($label); $name = \Template::instance()->build($name); $value = \Template::instance()->build($value); $selected = \Template::instance()->build($selected); if(defined("BulmaFormHelper::$type")){ $type_const = constant("BulmaFormHelper::$type"); switch( $type_const ){ case BulmaFormHelper::H_FIELD_INPUT: return BulmaFormHelper::build_h_field_input($label, $name, $value); break; case BulmaFormHelper::H_FIELD_TEXTAREA: return BulmaFormHelper::build_h_field_textarea($label, $name, $value); break; case BulmaFormHelper::H_FIELD_SELECT: return BulmaFormHelper::build_h_field_select($label, $name, $options, $selected); break; case BulmaFormHelper::H_FIELD_SELECT_NEW: return BulmaFormHelper::build_h_field_select_new($attr); break; case BulmaFormHelper::FIELD_INPUT: return BulmaFormHelper::build_field_input($label, $name, $value, $class); break; case BulmaFormHelper::FIELD_TEXTAREA: return BulmaFormHelper::build_field_textarea($label, $name, $value, $class, $rows); break; case BulmaFormHelper::FIELD_SELECT: return BulmaFormHelper::build_field_select($attr); break; default: return '
Error: Bulma CSS Form TYPE ('.$type.') not defined.
'; break; } } else { return '
Error: Bulma CSS Form TYPE not defined.
'; } } static function build_field_input($label, $name, $value, $class, $rows=10){ $string_label = $label !== '' ? sprintf('', $label) : ''; $string = '
%1$s
'; return sprintf($string, $string_label, $name, $value, $class, $rows); } static function build_field_textarea($label, $name, $value, $class, $rows=10) { $string_label = $label !== '' ? sprintf('', $label) : ''; $string = '
%1$s
'; return sprintf($string, $string_label, $name, $value, $class,$rows); } static function build_h_field_textarea($label, $name, $value){ $string = '
'; return $string; } static function build_h_field_input($label, $name, $value){ $string = '
'; return $string; } /** * build_field_select_new * * `` * * @param mixed $attr * @return void */ static function build_field_select($attr) { $f3 = \Base::instance(); $class = $attr['class'] ?? ''; $label = $attr['label'] ?? ''; $name = $attr['name'] ?? ''; // $options_arr = $attr['options'] ?? []; $option_value = $attr['option_value'] ?? 'id'; $option_name = $attr['option_name'] ?? 'name'; $options = \Template::instance()->token($attr['options']); $selected = \Template::instance()->token($attr['selected']); // TODO: label - this could be moved into a seperate function $html_label = $label !== '' ? sprintf('', $label) : ''; $tmp_options = 'field_select('. $options.', '.$selected.', "'.$option_value.'", "'.$option_name.'"); ?>'; $html = '
%1$s
'; return sprintf($html, $html_label, $tmp_options, $name, $class); } function field_select($options, $selected, $option_value, $option_name){ $html_options = ''; foreach ($options as $option) { $value = $option[$option_value] ?? ''; $text = $option[$option_name] ?? ''; $html_selected = ((string)$value === (string)$selected) ? ' selected="selected"' : ''; $html_option = ''; $html_options .= sprintf($html_option, $value, $html_selected, $text); } echo $html_options; } static function build_h_field_select_new($attr) { $f3 = \Base::instance(); $label = $attr['label'] ?? ''; $name = $attr['name'] ?? ''; $options_arr = $attr['options'] ?? []; $optionValue = $attr['option_value'] ?? 'id'; $optionName = $attr['option_name'] ?? 'name'; $selected = $attr['selected'] ?? ''; $options = $f3->get($options_arr); $html = '
'; if (!empty($label)) { $html .= ''; } $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; return $html; } static function build_h_field_select($label, $name, $options, $selected){ $opts = json_decode(str_replace("'", '"', $options)); $opts_string = ""; foreach($opts as $k => $v){ if($v == $selected){ $selected_str = " selected"; } else { $selected_str = ""; } $opts_string .= ''.$v.''; } $string = '
'; return $string; } } \Template::instance()->extend('bulma', 'BulmaFormHelper::render'); --- End File: app/extensions/BulmaFormHelper.php --- --- File: app/extensions/CSRFHelper.php --- exists('SESSION.' . self::TOKEN_NAME)) { $token = bin2hex(random_bytes(32)); $f3->set('SESSION.' . self::TOKEN_NAME, $token); } return $f3->get('SESSION.' . self::TOKEN_NAME); } public static function verify(?string $submitted_token): bool { $f3 = \Base::instance(); $session_token = $f3->get('SESSION.' . self::TOKEN_NAME); if(empty($submitted_token) || empty($session_token)){ return false; } if(hash_equals($session_token, $submitted_token)){ $f3->clear('SESSION.' . self::TOKEN_NAME); return true; } return false; } public static function field(): string { return ''; } } --- End File: app/extensions/CSRFHelper.php --- --- File: app/extensions/IconsHelper.php --- ['fas fa-circle-dot has-text-success', "new"], 'in_progress' => ['fas fa-circle-play has-text-link', "reload"], 'on_hold' => ['fas fa-pause-circle has-text-warning',"pause"], 'completed' => ['fas fa-check has-text-danger', "check"] ]; static public $status_names = [ 'open' => 'Open', 'in_progress' => 'In Progress', 'on_hold' => 'On Hold', 'completed' => 'Completed' ]; static public $priority_icons = [ 'Low' => ['fas fa-circle-down',"green"], 'Medium' => ['fas fa-circle-dot', "yellow"], 'High' => ['fas fa-circle-up', "red"] ]; static public $priority_colors = [ 'Low' => 'success', 'Medium' => 'warning', 'High' => 'danger', '' => 'info' ]; static public function icons($node){ // debug_print($node); $required = ['type', 'path']; $check = self::checkAttributes($node, $required); if(!is_null($check)){ return sprintf('
%s
', $check); } $attr = $node['@attrib']; $type = $attr['type']; $path = $attr['path']; $selected = $attr['selected']; switch($attr['type']){ case 'status-selector': // $selected = Base::instance()->get('GET.status') ?: null; return 'renderStatusSelector( Base::instance()->get("GET.status") ?: null, "'.$path.'"); ?>'; return self::renderStatusSelector($selected, $path); default: return '
unknown icon selector type
'; } $tpl = Template::instance(); $f3 = Base::instance(); $context = $f3->hive(); $inner = $tpl->token($node[0], $context); return ''; } private static function checkAttributes($node, array $required){ if(isset($node['@attrib'])){ $attr = $node['@attrib']; $errors = []; foreach($required as $key){ if(empty($attr[$key])){ $errors[] = "Error: '$key' is missing."; } } return empty($errors) ? null : implode(" ", $errors); } return "Error: '@attrib' is missing"; } public static function renderStatusSelector($current_status, $path) { $output = '
'; foreach (self::$status_icons as $k => $icon) { $active = ($current_status == $k); $url = $path . ($active ? '' : '/?status=' . $k); $class = 'button' . ($active ? ' is-inverted' : ''); $output .= '

'; $output .= ''; $output .= ''; $output .= '' . self::$status_names[$k] . ''; $output .= ''; $output .= '

'; } $output .= '
'; return $output; } static function do_the_switch($type, $value){ if($value !== null) { $value = str_replace(' ', '_', strtolower($value)); } $icon_class = ''; switch(strtolower($type)){ case 'status': $icon_class = IconsHelper::$status_icons[$value] ?? ['fas fa-question-circle has-text-info', "🔲"]; break; case 'priority': $icon_class = IconsHelper::$priority_icons[$value] ?? ['fas fa-question-circle', "🔲"]; $icon_color = IconsHelper::$priority_colors[$value] ?? 'info'; break; default: $icon_class = 'fas fa-question-circle'; } if($type == 'priority'){ // return '

' return ' '; } else { return ''; } return ''.$icon_class[1].''; } } \Template::instance()->extend('icons', 'IconsHelper::icons'); --- End File: app/extensions/IconsHelper.php --- --- File: app/extensions/ParsedownHelper.php --- #markdown here if(isset($args['@attrib']) && isset($args['@attrib']['inline']) && $args['@attrib']['inline'] === 'true'){ return self::instance()->inline($args); } # href if(isset($args['@attrib']) && isset($args['@attrib']['href'])) { return self::instance()->load_href($args); } # token {@variable} $content = $args[0]; $content_token = \Template::instance()->token($content); return ' build('.$content_token.'); ?> '; } function build($content){ return \ParsedownTableExtension::instance()->text($content); } static private function inline($args){ $return = \Parsedown::instance()->text($args[0]); return '

'.$return.'
'; } static private function load_href($args){ $href= $args['@attrib']['href'] ?? ''; if(!$href){ return ''; } $ui = Base::instance()->get('UI'); $dirs = preg_split('#[;]+#', $ui, -1, PREG_SPLIT_NO_EMPTY); // look for the file in each UI dir $file = ''; foreach ($dirs as $dir) { // normalize trailing slash $base = rtrim($dir, '/').'/'; // resolve relative paths $candidate = realpath($base . $href) ?: $base . $href; // print_r("

".$candidate . "

"); if (is_readable($candidate)) { $file = $candidate; break; } } if(!$file) { return "

File not found: {$href}

"; } $text = file_get_contents($file); $md = \Parsedown::instance()->text($text); return ' '.$md.' '; } } \Template::instance()->extend('parsedown', 'ParsedownHelper::render'); --- End File: app/extensions/ParsedownHelper.php --- --- File: app/extensions/ParsedownTableExtension.php --- 'table', // 'handler' => 'elements', // 'text' => [ ... ], // 'attributes' => [...], // ] // Add your custom class to the itself: if (!isset($Block['element']['attributes'])) { $Block['element']['attributes'] = []; } $Block['element']['attributes']['class'] = 'table is-bordered'; // Wrap the
in a
: $wrapped = [ 'name' => 'div', 'attributes' => [ 'class' => 'table-container', ], 'handler' => 'elements', 'text' => [ $Block['element'], // the
itself ], ]; // Replace the original element with our wrapped version: $Block['element'] = $wrapped; } return $Block; } } --- End File: app/extensions/ParsedownTableExtension.php --- --- File: app/interfaces/CRUD.php --- db->exec( 'SELECT a.*, u.username FROM attachments a LEFT JOIN users u ON u.id = a.uploaded_by WHERE a.ticket_id = ? ORDER BY a.created_at DESC', [$ticket_id] ); } } --- End File: app/models/Attachment.php --- --- File: app/models/Comment.php --- db->exec( 'SELECT c.*, u.username AS author_name FROM ticket_comments c LEFT JOIN users u ON c.created_by = u.id WHERE c.ticket_id = ? ORDER BY c.created_at DESC', [$ticket_id] ); } } --- End File: app/models/Comment.php --- --- File: app/models/Tag.php --- tag_table = $type . '_tags'; $this->tag_table_id = $type . '_id'; parent::__construct($db, $this->tag_table); } return $this; } // VERIFY: possible issue with this? public function getTagsFor($objects, $id_key = 'id') { // echo $this->get('_type_id'); exit; // printf('
%s
', print_r($this,1)); exit; if(empty($objects)) return []; $ids = array_column($objects, $id_key); $placeholders = implode(',', array_fill(0, count($ids), '?')); $sql = 'SELECT tt.%1$s, t.id, t.name, t.color FROM %2$s tt INNER JOIN tags t ON tt.tag_id = t.id WHERE tt.%1$s IN (%3$s)'; $sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table, $placeholders); $rows = $this->db->exec($sql_sprintf, $ids); $tags_map = []; foreach($rows as $row) { $tags_map[$row[$this->tag_table_id]][] = $row; } foreach($objects as &$object) { $object['tags'] = $tags_map[$object[$id_key]] ?? []; } return $objects; } public function getTagsForID($id, $id_key = 'id') { $sql = 'SELECT tt.%1$s, t.id, t.name, t.color FROM %2$s tt INNER JOIN tags t ON tt.tag_id = t.id WHERE tt.%1$s = ?'; $sql_sprintf = sprintf($sql, $this->tag_table_id, $this->tag_table); $rows = $this->db->exec($sql_sprintf, $id); return $rows; } public function findLinkedTags($id = '') { $sql = ' SELECT t.name, t.color FROM `?` tt LEFT JOIN `tags` t ON t.id = tt.id WHERE tt.`?` = ? '; $params = [ $this->_type, $this->_type_id, $id ]; return $this->db->exec($sql, $params); } } --- End File: app/models/Tag.php --- --- File: app/models/Ticket.php --- db->exec( 'SELECT t.id, t.title, t.created_at, tp.name AS priority_name, ts.name AS status_name, u.display_name FROM tickets t LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id LEFT JOIN ticket_statuses ts ON t.status_id = ts.id LEFT JOIN users u ON t.created_by = u.id WHERE t.recycled = 0 ORDER BY t.created_at DESC' ); $result = $this->getTagsForTickets($tickets); return $result; } public function findFiltered(string $filter): array { $sql = ' SELECT t.*, tp.name AS priority_name, ts.name AS status_name, u.display_name FROM tickets t LEFT JOIN ticket_priorities tp ON t.priority_id = tp.id LEFT JOIN ticket_statuses ts ON t.status_id = ts.id LEFT JOIN users u ON t.created_by = u.id WHERE t.recycled = 0 '; $params = []; switch($filter){ case 'open': $sql .= ' AND status_id = ?'; $params[] = 1; break; case 'in_progress': $sql .= ' AND status_id = ?'; $params[] = 2; break; case 'on_hold': $sql .= ' AND status_id = ?'; $params[] = 3; break; case 'completed': $sql .= ' AND status_id = ?'; $params[] = 4; break; } $sql .= ' ORDER BY t.created_at DESC'; $tickets = $this->db->exec($sql, $params); $result = $this->getTagsForTickets($tickets); return $result; } public function getTagsForTickets(array $tickets) { if(empty($tickets)) return []; $tag_mapper = new Tag($this->db, 'ticket'); $tickets = $tag_mapper->getTagsFor($tickets); return $tickets; } public function findById($id): ?Ticket { $this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id'; $this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id'; $this->load(['id = ?', $id]); if($this->dry()){ return null; } $tag_model = new Tag($this->db, 'ticket'); $this->tags = $tag_model->getTagsForID($this->id, 'ticket_id'); return $this; } public function setTags(array $tags_ids):void { if($this->dry() || !$this->id){ // can't set tags for a ticket that hasn't been saved or loaded return; } // remove existing tags - TODO: shouldn't this be in the tag model? $this->db->exec('DELETE FROM ticket_tags WHERE ticket_id = ?', [$this->id]); if(!empty($tags_ids)){ $sql_insert_tag = 'INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)'; foreach($tags_ids as $tag_id){ if(filter_var($tag_id, FILTER_VALIDATE_INT)){ $this->db->exec($sql_insert_tag, [$this->id, (int)$tag_id]); } } } // refresh tags $tag_model = new Tag($this->db, 'ticket'); $this->tags = $tag_model->getTagsForID($this->id, 'ticket_id'); } public function createTicket(array $data): int { $this->reset(); $this->title = $data['title'] ?? ''; $this->description = $data['description'] ?? ''; // $this->priority_id = $data['priority_id'] ?? null; $this->status_id = $data['status_id'] ?? null; // $this->created_by = $data['created_by'] ?? null; $this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s'); $this->updated_at = date('Y-m-d H:i:s'); $this->save(); $this->logCreate(); return (int)$this->id; } public function updateTicket(array $data): void { $this->logDiff($data); if(isset($data['title'])){ $this->title = $data['title']; } if(isset($data['description'])) { $this->description = $data['description']; } if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; } if(isset($data['status_id'])) { $this->status_id = $data['status_id']; } if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; } if(isset($data['assigned_to'])) { if($data['assigned_to'] == '-1'){ $this->assigned_to = null; } else { $this->assigned_to = $data['assigned_to']; } } $this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s'); $this->updated_at = date('Y-m-d H:i:s'); $this->save(); } public function softDelete():void { $this->recycled = 1; $this->save(); } public function attachments(){ $attachment = new Attachment($this->db); return $attachment->findWithUserByTicketId($this->id); } public function comments(){ $comment = new Comment($this->db); return $comment->findWithUserByTicketId($this->id); } public function getParentTickets() { return $this->db->exec( 'SELECT p.* FROM ticket_relations r INNER JOIN tickets p ON r.parent_ticket_id = p.id WHERE r.child_ticket_id = ?', [$this->id] ); } public function getChildTickets() { return $this->db->exec( 'SELECT c.* FROM ticket_relations r INNER JOIN tickets c ON r.child_ticket_id = c.id WHERE r.parent_ticket_id = ?', [$this->id] ); } public function addChildTicket(int $childId) { $this->db->exec( 'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id) VALUES (?, ?)', [$this->id, $childId] ); } // meta data public function getMeta() { return $this->db->exec( 'SELECT id, meta_key, meta_value FROM ticket_meta WHERE ticket_id = ?', [$this->id] ); } public function getMetaAssoc() { $rows = $this->getMeta(); $assoc = []; foreach($rows as $row){ $assoc[$row['meta_key']] = $row['meta_value']; } return $assoc; } public function assocExistingMeta($meta_ids, $meta_keys, $meta_values){ if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){ $field_assoc = []; foreach($meta_ids as $i => $m_id){ $key = $meta_keys[$i] ?? ''; $value = $meta_values[$i] ?? ''; if(!empty($key) && $value !== ''){ $field_assoc[$key] = $value; } } return $field_assoc; } return []; } public function assocMetaFromKeyValue($meta_keys, $meta_values) { if(is_array($meta_keys) && is_array($meta_values)){ $field_assoc = []; foreach($meta_keys as $i => $key){ $val = $meta_values[$i] ?? ''; if(!empty($key) && $val != ''){ $field_assoc[$key] = $val; } } return $field_assoc; } return []; } public function setCustomFields(array $fields) { $this->db->exec( 'DELETE FROM ticket_meta WHERE ticket_id = ?', [$this->id] ); foreach($fields as $key => $value){ $this->db->exec( 'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value) VALUES (?, ?, ?)', [$this->id, $key, $value] ); } } public function getAssignedUser() { if(!$this->assigned_to){ return null; } $sql = ' SELECT id, username, display_name FROM users WHERE id =? '; $user = $this->db->exec($sql, [$this->assigned_to]); return $user[0]; } /** * Logs a change to the ticket history. * @param int $user_id - the ID of the user making the change. * @param string $field_changed - the name of the field that was changed. * @param string|null $old_value - the old value * @param string|null $new_value - the new value */ public function logHistory(int $user_id, string $field_changed, $old_value = null, $new_value = null, $changed_at = null): void { if($this->dry() || !$this->id) return; $history = new \DB\SQL\Mapper($this->db, 'ticket_history'); $history->ticket_id = $this->id; $history->user_id = $user_id; $history->field_changed = $field_changed; $history->old_value = $old_value === null ? null : (string)$old_value; $history->new_value = $new_value === null ? null : (string)$new_value; if($changed_at == null){ $history->changed_at = date('Y-m-d H:i:s'); } else { $history->changed_at = $changed_at; } $history->save(); } public function getHistory(): array { if($this->dry() || !$this->id) return[]; $sql = 'SELECT th.*, u.display_name as user_display_name FROM ticket_history th JOIN users u ON th.user_id = u.id WHERE th.ticket_id = ? ORDER BY th.changed_at DESC'; return $this->db->exec($sql, [$this->id]); } /** * called from create */ public function logCreate(){ $changed_at = date('Y-m-d H:i:s'); $user_making_change = \Base::instance()->get('SESSION.user.id'); $this->logHistory($user_making_change, 'ticket_created', null, $this->title, $changed_at); if($this->status_id) $this->logHistory($user_making_change, 'status_id', null, $this->status_id, $changed_at); if($this->priority_id) $this->logHistory($user_making_change, 'priority_id', null, $this->priority_id, $changed_at); if($this->assigned_to) $this->logHistory($user_making_change, 'assigned_to', null, $this->assigned_to, $changed_at); } /** * called from update */ public function logDiff($data){ $user_making_change = \Base::instance()->get('SESSION.user.id'); $changed_at = date('Y-m-d H:i:s'); $checks = ['title', 'description', 'priority_id', 'status_id', 'assigned_to', 'updated_by']; // loop instead foreach($checks as $check){ if(isset($data[$check]) && $this->$check != $data[$check]){ if($check == 'description'){ $old_hash = hash('sha256', $this->$check); $new_hash = hash('sha256', $data[$check]); $this->logHistory($user_making_change, $check, $old_hash, $new_hash, $changed_at); } else { $this->logHistory($user_making_change, $check, $this->$check, $data[$check], $changed_at); } } } } } --- End File: app/models/Ticket.php --- --- File: app/models/TicketPriority.php --- db->exec( 'SELECT * FROM ticket_priorities ORDER BY sort_order ASC' ); } } --- End File: app/models/TicketPriority.php --- --- File: app/models/TicketStatus.php --- db->exec( 'SELECT * FROM ticket_statuses ORDER BY sort_order ASC' ); } } --- End File: app/models/TicketStatus.php --- --- File: app/traits/CheckCSRF.php --- get('POST.' . \CSRFHelper::TOKEN_NAME))){ $f3->set('SESSION.error', 'CSRF token validation failed.'); $f3->reroute($reroute); return; } } } --- End File: app/traits/CheckCSRF.php --- --- File: app/traits/RequiresAuth.php --- exists('SESSION.user')){ // $f3->set('SESSION.error', 'You don\'t have permission for this ticket.'); $f3->set('SESSION.redirect', $f3->get('PATH')); $f3->reroute('/login'); } } } --- End File: app/traits/RequiresAuth.php --- --- File: app/ui/issues.md --- # MVP Issues This issue list defines the work required to bring the `tp_servicedesk` Fat-Free PHP application to a beta-ready MVP for personal workload/project tracking. --- ## 🧩 Ticketing System - [x] Display assigned user in ticket view - [x] Add user assignment capability in ticket create/edit forms - [ ] Implement ticket filtering (by status, assignee, project) - [ ] Improve UI feedback for ticket soft-delete - [ ] Ensure metadata, attachments, and comments update properly on edit - [ ] Add tag support for tickets (UI and DB updates) - [ ] Implement ticket history for status/priority changes - [ ] Add comment thread display on ticket edit view - [x] Update "new ticket" template to match the edit form - [ ] Enable linking tickets using markdown shortcodes (e.g. `#ticket-id` autocomplete) --- ## 📂 Project Management - [ ] Implement `ProjectController::create()` and `update()` logic - [ ] Add CRUD for: - [ ] `project_tasks` (task list under project) - [ ] `project_links` (related links/resources) - [ ] `project_events` (project timeline/log) - [ ] Show tickets related to a project in the project view page --- ## 📚 Knowledge Base (KB) - [ ] Display and edit tags for each article - [ ] Show article creator and last updated info - [ ] Add search suggestions or recent articles on main KB page --- ## 👤 User Management - [ ] Implement full CRUD in `Admin\UserController` - [ ] Add role selection to user creation/edit forms - [ ] Restrict sensitive actions (ticket edit/delete) to owner or admin --- ## ⚙️ Admin Panel - [ ] Complete admin routes and views for ticket status management - [ ] Add dashboard widgets (open tickets count, recent updates, etc.) - [ ] Create settings/config view to manage global app options --- ## 📦 UI & UX Polish - [ ] Show success/error alerts for all user actions - [ ] Apply consistent Bulma styling to forms, buttons, and messages - [ ] Add loading indicators or skeletons on longer actions - [ ] Embed `/public/test.md.php` JS markdown editor into edit/create ticket templates - [ ] Add smart shortcuts to markdown editor: - [ ] `#` opens ticket linking modal - [ ] `@` opens user tagging modal - [ ] `!a` opens hyperlink creation modal --- ## 🧪 Quality Assurance - [ ] Create fake/test data seeder - [ ] Add DB integrity checks for tickets, users, and project FK links - [ ] Manually test: - [x] Login/logout - [x] Ticket create/edit/delete - [ ] KB CRUD - [ ] Attachment upload/download - [ ] Audit all routes and controllers for authentication and access checks --- ## 🔍 Search & Indexing - [ ] Improve full-text search to be smarter than `LIKE %{query}%` - [ ] Add partial, fuzzy, and prioritised relevance to results (e.g. `title > tags > content`) --- ## 📅 Timeline & Activity Views - [ ] Create timeline view per ticket using: - [ ] status/priority history - [ ] comments - [ ] attachments and other actions - [ ] Build global timeline dashboard to see activity across system - [ ] Implement calendar heatmap (e.g. GitHub-style) for activity tracking - [ ] Add drilldown support from calendar to view specific actions on each day - [ ] Add detailed log/summary of: - [ ] ticket creation/updates - [ ] project creation/updates - [ ] assignments - [ ] comments - [ ] closures --- ## 🔐 Security & Session - [x] Add CSRF protection for all POST forms - [ ] Enforce permission checks on: - [ ] Ticket edits - [ ] Comment deletion - [ ] Attachment deletion - [ ] Add password reset flow or admin-reset function --- ## 📈 Post-MVP (Optional Enhancements) - [ ] Ticket due dates and reminders - [ ] Kanban or Gantt-style project view - [ ] REST API endpoints --- --- End File: app/ui/issues.md --- --- File: app/ui/partials/ticket_item.html ---
{{@ticket.status_name}}
{{ @ticket.title }}
{{ @tag.name }}

#{{ @ticket.id }} opened {{ @ticket.created_at }} by {{ @ticket.display_name }}

--- End File: app/ui/partials/ticket_item.html --- --- File: app/ui/parts/clipboard.html ---
Paste or drag an image here

--- End File: app/ui/parts/clipboard.html --- --- File: app/ui/session/error.html ---
{{ @SESSION.error }}
--- End File: app/ui/session/error.html --- --- File: app/ui/templates/layout.html --- TP ServiceDesk
--- End File: app/ui/templates/layout.html --- --- File: app/ui/views/dashboard.html ---

Dashboard

--- End File: app/ui/views/dashboard.html --- --- File: app/ui/views/home.html ---

TP ServiceDesk

One place to manage requests, store knowledge, and collaborate on projects

Get Started Browse Knowledge Base

Ticketing System

  • Create & Track tickets
  • Assign priorities & statuses
  • Link child/parent tickets

Knowledge Base

  • Markdown-powered articles
  • Tagging and filtering
  • Fast searching

Projects

  • Track ongoing projects
  • Integreate tasks and tickets
  • Monitor progress

Collaboration

  • Comment threads
  • File attachments
  • Role-based user access

Custom fields

  • Define ticket meta data
  • Configure and store extra info
  • Easily editable in forms

Administration

  • Manage user roles
  • Create new account
  • Edit existing users
--- End File: app/ui/views/home.html --- --- File: app/ui/views/login.html ---

Please Log In

{{ @error }}

{{ \CSRFHelper::field() | raw }}

--- End File: app/ui/views/login.html --- --- File: app/ui/views/admin/index.html ---

Admin


Ticket > Priorities

Ticket > Statuses

--- End File: app/ui/views/admin/index.html --- --- File: app/ui/views/admin/priorities/create.html ---

Create Ticket Priority

TODO:

{{ \CSRFHelper::field() | raw }} --- End File: app/ui/views/admin/priorities/create.html --- --- File: app/ui/views/admin/priorities/index.html ---

Admin: Ticket Priorities

create priority


id name sort_order
{{@priority.id}} {{@priority.name}} {{@priority.sort_order}}
--- End File: app/ui/views/admin/priorities/index.html --- --- File: app/ui/views/attachment/index.html ---

Attachments

File Name Uploaded By Created At Version
{{ @attach.file_name }} {{ @attach.username }} {{ @attach.created_at }} {{ @attach.version_number }}

{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/attachment/index.html --- --- File: app/ui/views/comments/view.html ---

Comments

{{ @comment.author_name}} {{ @comment.created_at }}
{{ @comment.comment | raw }}
{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/comments/view.html --- --- File: app/ui/views/kb/create.html ---

Create Knowledge Base Article

{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/kb/create.html --- --- File: app/ui/views/kb/edit.html ---

Edit Knowledge Base Article

{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/kb/edit.html --- --- File: app/ui/views/kb/index.html ---

Knowledge Base

create kb article


idtitlecreated_at
{{@article.id}} {{@article.title}} {{@article.created_at}}

No articles found.

--- End File: app/ui/views/kb/index.html --- --- File: app/ui/views/kb/view.html ---

{{@article.title}}

edit article


{{ @article.content | raw }}
PropertyValue
{{@key}} {{@value}}
--- End File: app/ui/views/kb/view.html --- --- File: app/ui/views/project/create.html ---
    TODO: create form.
--- End File: app/ui/views/project/create.html --- --- File: app/ui/views/project/edit.html ---
    TODO: edit form
--- End File: app/ui/views/project/edit.html --- --- File: app/ui/views/project/index.html ---

Projects

create project


ID Title Requester Created By Created At Start Date End Date
{{ @p.id }} {{ @p.title }} {{ @p.requester }} {{ @p.created_by }} {{ @p.created_at }} {{ @p.start_date }} {{ @p.end_date }}
--- End File: app/ui/views/project/index.html --- --- File: app/ui/views/project/view.html ---

{{ @project.title }}

edit project


Overview

{{ @project.description }}

Links


Tickets

Tasks

Events


Timeline

--- ## View project A central place to see everything for this project: - Overview: (title, description, links, start/end dates). - related tickets (with status and priorities) - events - tasks - timeline combining events, tickets, milestone dates ## Example Workflow - create a project - `team manager overview` -- attach relevant links - add tickets - each new request or issue can be a ticket referencing this project - add events - quick notes about management meetings, or verbal discussions that don't need ticket overhead -- meeting on 01 jan to discuss layout -- teams message on 28 jan clarifying data requirements - project tasks - for smaller to do items that don't warrant a full ticket -- identify location of required data, create initial pq connections, build a mockup layout ## Reporting Timelines - timeline view - merge ticket data with project_events sorted by date - chronological Overview - status summaries - how many tickets open, on hold, completed - progress tracking - sumarries or gantt style charts --- End File: app/ui/views/project/view.html --- --- File: app/ui/views/tag/create.html ---

Create Tag

{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/tag/create.html --- --- File: app/ui/views/tag/index.html ---

Tags

create tag


No tags found

Color Examples

The following color names can be used for tags

Black Dark Light White Primary Link Info Success Warning Danger
--- End File: app/ui/views/tag/index.html --- --- File: app/ui/views/ticket/create.html ---
{{ \CSRFHelper::field() | raw }}

Hold Ctrl to select multiple.

--- End File: app/ui/views/ticket/create.html --- --- File: app/ui/views/ticket/create.html.v1 ---

Create Ticket Form


--- End File: app/ui/views/ticket/create.html.v1 --- --- File: app/ui/views/ticket/edit.html ---
{{ \CSRFHelper::field() | raw }}

Hold Ctrl to select multiple tags.

Property Value
{{@key}} {{@value}}

Linked Tickets

Parent Tickets

Child Tickets

*/ ?>

--- End File: app/ui/views/ticket/edit.html --- --- File: app/ui/views/ticket/edit.html.v1 ---

Edit Ticket Form

Custom Fields


--- End File: app/ui/views/ticket/edit.html.v1 --- --- File: app/ui/views/ticket/index.html ---

Tickets


--- End File: app/ui/views/ticket/index.html --- --- File: app/ui/views/ticket/index_row.html ---
{{@ticket.status_name}}

#{{@ticket.id}} opened 2025-03-25 by {{@ticket.display_name}}

--- End File: app/ui/views/ticket/index_row.html --- --- File: app/ui/views/ticket/view.html ---

{{ @ticket.title }}


{{ @ticket.created_at }}

{{ @ticket.description | raw }}

Tags
{{@tag.name}}

Projects

Assigned User:

{{ @assigned_user.display_name ?: @assigned_user.username }}

Participants
Time Tracker?
Dependencies

Reference:

Delete

Linked Tickets

Parent Tickets

Child Tickets

{{ \CSRFHelper::field() | raw }}

Ticket History

{{ date('Y-m-d H:i:s', strtotime(@entry.changed_at)) }} by {{ @entry.user_display_name }}
Ticket created: {{ @entry.new_value}} Status changed from {{ @map['status'][@entry.old_value] ?? 'Unknown' }} to {{ @map['status'][@entry.new_value] ?? 'Unknown' }} Priority changed from {{ @map['priorities'][@entry.old_value] ?? 'Unknown'}} to {{ @map['priorities'][@entry.new_value] ?? 'Unknown' }} Assignment changed from {{ @map['users'][@entry.old_value] ?? 'Unassigned' }} from Unassigned to {{ @map['users'][@entry.new_value] ?? 'Unassigned' }} Unassigned Title changed from "{{ @entry.old_value}}" to "{{ @entry.new_value}}". Description updated old sha256({{@entry.old_value}}).

No history entries for this ticket.

--- End File: app/ui/views/ticket/view.html --- --- File: app/ui/views/user/edit.html ---
{{ \CSRFHelper::field() | raw }}
--- End File: app/ui/views/user/edit.html --- --- File: app/ui/views/user/index.html ---

All Users

IDUsernameRoleActions
{{ @u.id }} {{ @u.username }} {{ @u.role_name }} ( {{ @u.role }} )
--- End File: app/ui/views/user/index.html --- --- File: public/index.php --- config('../app/config/.env.cfg'); $f3->set('DEBUG', 3); // development debug $f3->set('CACHE', FALSE); /** * Not required yet */ $htmlpurifier = \HTMLPurifier::instance(); // $htmlpurifier->purify($input); $md = \ParsedownTableExtension::instance(); $md->setSafeMode(true); $f3->set('EXT', [new ParsedownHelper, new BulmaFormHelper, new IconsHelper]); $f3->set('DB', new \DB\SQL( 'mysql:host=localhost;port=3306;dbname=' . $f3->get('database.db_name'), $f3->get('database.username'), $f3->get('database.password') )); new \DB\SQL\Session($f3->get('DB')); $f3->set('SESSION.status', 'running'); $f3->run(); --- End File: public/index.php --- --- File: public/logo.svg --- --- End File: public/logo.svg --- --- File: public/style.css --- html, body {padding:0; margin:0;} html, body, #sidebar, #page,#base_body { min-height: 100% } #page { min-height: calc(100vh - 170px - 52px) } i.fa { font-weight: 100 !important ; } .table th.th-icon { width: 2rem; } #ticket_list .g-flex-item { border-bottom: 1px solid var(--bulma-text-soft); } a { word-break: break-word; } /* parsedown check-checkbox */ li.parsedown-task-list { list-style: none; } /* List Component */ .list{ --be-list-color:var(--bulma-text); --be-list-item-description-color:var(--bulma-text-50); --be-list-item-divider-color:var(--bulma-border); --be-list-item-hover-color:var(--bulma-scheme-main-bis); --be-list-item-image-margin:.75em; --be-list-item-padding:.75em; --be-list-item-title-color:var(--bulma-text-strong); --be-list-item-title-weight:var(--bulma-weight-semibold); color:var(--be-list-color); flex-direction:column; display:flex } .list.has-hidden-images .list-item-image{ display:none } .list.has-hoverable-list-items .list-item:hover{ background-color:var(--be-list-item-hover-color) } .list.has-overflow-ellipsis .list-item-content{ min-inline-size:0; max-inline-size:calc(var(--length)*1ch) } .list.has-overflow-ellipsis .list-item-content>*{ text-overflow:ellipsis; white-space:nowrap; overflow:hidden } @media (hover:hover){ .list:not(.has-visible-pointer-controls) .list-item-controls{ opacity:0; visibility:hidden } } .list .list-item{ align-items:center; transition:background-color .125s ease-out; display:flex; position:relative; /* TP: update + align top */ align-items: flex-start; } @media (hover:hover){ .list .list-item:hover .list-item-controls,.list .list-item:focus-within .list-item-controls{ opacity:initial; visibility:initial } } .list .list-item:not(.box){ padding-block:var(--be-list-item-padding); padding-inline:var(--be-list-item-padding) } .list .list-item:not(:last-child):not(.box){ border-block-end:1px solid var(--be-list-item-divider-color) } @media screen and (width<=768px){ .list:not(.has-overflow-ellipsis) .list .list-item{ flex-wrap:wrap } } .list .list-item-image{ flex-shrink:0; margin-inline-end:var(--be-list-item-image-margin); /* TP: update + add margin-top */ margin-top: 0.5rem; } @media screen and (width<=768px){ .list .list-item-image{ padding-block:.5rem; padding-inline:0 } } .list .list-item-content{ flex-direction:column; flex-grow:1; display:flex } @media screen and (width<=768px){ .list .list-item-content{ padding-block:.5rem; padding-inline:0 } } .list .list-item-title{ color:var(--be-list-item-title-color); font-weight:var(--be-list-item-title-weight); margin-bottom: .25rem; } .list .list-item-description{ color:var(--be-list-item-description-color) } .list .list-item-controls{ flex-shrink:0; transition:opacity .125s ease-out } @media screen and (width<=768px){ .list .list-item-controls{ flex-wrap:wrap; padding-block:.5rem; padding-inline:0 } } @media screen and (width>=769px),print{ .list .list-item-controls{ padding-inline-start:var(--be-list-item-padding) } .list:not(.has-visible-pointer-controls) .list .list-item-controls{ block-size:100%; align-items:center; padding-block-end:var(--be-list-item-padding); display:flex; position:absolute; inset-inline-end:0 } } --- End File: public/style.css --- --- File: public/test.md.php ---

MD Testing

1. MD CONTENT 2. list item two and something else - and then - and then - and then --- End File: public/test.md.php --- --- File: public/js/kb_edit.js --- // switch to target tab pane function switchTab(targetId){ var panes = document.querySelectorAll('.tab-content .tab-pane'); for (var i=0; i< panes.length; i++){ panes[i].style.display = 'none'; } var targetPane = document.getElementById(targetId); if(targetPane){ targetPane.style.display = 'block'; } } // send ajax post request with content to specified url function ajaxPost(content, url, callback){ var xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.onreadystatechange = function(){ if(xhr.readyState === XMLHttpRequest.DONE){ if(xhr.status === 200){ callback(xhr.responseText); } else { console.error("AJAX error: " + xhr.status); } } }; var params = 'content=' + encodeURIComponent(content); xhr.send(params); } // load preview via ajax into preview element function loadPreview(previewElement){ var sourceId = previewElement.getAttribute('data-source'); var handlerUrl = previewElement.getAttribute('data-handler'); var method = previewElement.getAttribute('data-method'); var sourceElement = document.getElementById(sourceId); if(sourceElement){ var content = sourceElement.value; if(method && method.toLowerCase() == 'post'){ ajaxPost(content, handlerUrl, function (response){ previewElement.innerHTML = response; }); } } } // initialise tab links to handle tab switching function initTabs(){ var tabLinks = document.querySelectorAll('.tabs a[data-target]'); for(var i=0; i { link.addEventListener('click', (e) => this.handleTabClick(e, link)); }); } async handleTabClick(e, link) { e.preventDefault(); const selectedTab = link.getAttribute('data-tab'); // Update active tab this.tabParent.querySelectorAll('li').forEach(li => li.classList.remove('is-active')); link.parentElement.classList.add('is-active'); // Show active content document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none'); const activeContent = document.getElementById(`${this.contentPrefix}-${selectedTab}`); if (activeContent) activeContent.style.display = ''; if (selectedTab === 'preview') { await this.loadPreview(); } } async loadPreview() { const previewTarget = document.getElementById('preview-output'); if (!previewTarget) return; previewTarget.innerHTML = `
`; await new Promise(resolve => setTimeout(resolve, 500)); const textarea = document.querySelector(this.textareaSelector); const markdown = textarea ? textarea.value : ''; const res = await fetch(this.previewUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `content=${encodeURIComponent(markdown)}` }); const html = await res.text(); previewTarget.innerHTML = html; } } // Usage document.addEventListener('DOMContentLoaded', () => { new TabSwitcherController({ tabSelector: '.tabs', contentPrefix: 'tab', textareaSelector: '#description', previewUrl: '/parsedown/preview' }); }); --- End File: public/js/markdown_preview.js --- --- File: public/js/ticket_view.js --- document.addEventListener('DOMContentLoaded', function(){ const ticket_id = window.location.pathname.split('/')[2]; const comments_url = `/ticket/${ticket_id}/comments`; const attachments_url = `/ticket/${ticket_id}/attachments`; function ajax(url, containerID){ fetch(url) .then(response => { if(!response.ok){ throw new Error('Network response was not ok.'); } return response.text(); }) .then(html => { const container_el = document.getElementById(containerID); if(container_el){ container_el.innerHTML += html; } else { throw new Error('Coments container does not exist'); } }) .catch(error => { console.log('Error fetching comments', error); }); } ajax(attachments_url, 'attachments') ajax(comments_url, 'comments') }); --- End File: public/js/ticket_view.js --- --- File: public/js/tp_md_editor.js --- /** * tp_md_editor.js * Self-contained Markdown Editor with Toolbar Buttons * Usage: tp_md_editor.init(config) */ class TPMarkdownEditor { static init(config = {}) { document.querySelectorAll('tp-md-editor').forEach(editor => { new TPMarkdownEditor(editor, config); }); } constructor(wrapper, config) { this.wrapper = wrapper; this.name = wrapper.getAttribute('name'); this.config = config; this.textarea = document.createElement('textarea'); this.textarea.name = this.name; this.textarea.rows = 25; this.toolbar = document.createElement('tp-md-toolbar'); this.undoStack = [] this.redoStack = [] this.localStorageKey = this.getStorageKey(); this.unsaved = false; this.autosaveInterval = null; // this.loadInitialContent(); this.captureInitialContent(); this.createUnsavedBanner(); this.loadFromLocalStorage(); this.wrapper.appendChild(this.unsavedBanner); this.wrapper.appendChild(this.toolbar); this.wrapper.appendChild(this.textarea); this.buttonClasses = TPMarkdownEditor.defaultButtons(); this.buildToolbar(); this.setupAutoList(); this.setupUndoRedo(); this.setupPersistence(); this.setupAutoSave(); } getStorageKey(){ const path = window.location.pathname; return `tp_md_editor:${path}:${this.name}`; } createUnsavedBanner(){ const container = document.createElement('div'); container.style.cssText = 'background: #fff3cd; color: #856404; padding: 5px 10px; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center; display: none;'; const text = document.createElement('span'); text.textContent = 'You have unsaved changes.'; const discardBtn = document.createElement('button'); discardBtn.textContent = 'Discard'; discardBtn.style.cssText = 'margin-left: auto; background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer;'; discardBtn.addEventListener('click', () => { localStorage.removeItem(this.localStorageKey); const hidden = this.wrapper.querySelector('.tp-md-initial'); if(hidden){ this.textarea.value = hidden.textContent; } this.clearUnsaved(); }); container.appendChild(text); container.appendChild(discardBtn); this.unsavedBanner = container } markUnsaved(){ this.unsaved = true; this.unsavedBanner.style.display = 'block'; } clearUnsaved(){ this.unsaved = false; this.unsavedBanner.style.display = 'none'; } setupPersistence(){ window.addEventListener('beforeunload', (e) => { if(this.unsaved){ localStorage.setItem(this.localStorageKey, this.textarea.value); } }); } setupAutoSave(){ this.autosaveInterval = setInterval(() => { if(this.unsaved){ localStorage.setItem(this.localStorageKey, this.textarea.value); } }, 5000); // save every 5 sec } loadFromLocalStorage(){ const saved = localStorage.getItem(this.localStorageKey); if(saved){ this.textarea.value = saved; this.markUnsaved(); } } buildToolbar() { const groups = this.config.groups || [Object.keys(this.buttonClasses)]; groups.forEach(group => { const groupEl = document.createElement('tp-md-toolbar-group'); group.forEach(btnType => { const BtnClass = this.buttonClasses[btnType]; if (BtnClass) { const btn = new BtnClass(this.textarea); groupEl.appendChild(btn.element); } }); this.toolbar.appendChild(groupEl); }); } setupUndoRedo(){ this.textarea.addEventListener('input', ()=>{ this.undoStack.push(this.textarea.value); if(this.undoStack > 100) this.undoStack.shift(); this.redoStack = []; this.markUnsaved(); }); this.textarea.addEventListener('keydown', (e) => { if(e.ctrlKey && e.key === 'z'){ e.preventDefault(); if(this.undoStack.length > 0 ){ this.redoStack.push(this.textarea.value); this.textarea.value = this.undoStack.pop(); this.markUnsaved(); } } else if (e.ctrlKey && (e.key === 'y')) { // || e.shiftkey && e.key === 'z' e.preventDefault(); if(this.redoStack.length > 0){ this.undoStack.push(this.textarea.value); this.textarea.value = this.redoStack.pop(); this.markUnsaved(); } } }); } setupAutoList() { this.textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const pos = this.textarea.selectionStart; const before = this.textarea.value.slice(0, pos); const after = this.textarea.value.slice(pos); const lines = before.split('\n'); const lastLine = lines[lines.length - 1]; // need order of task > ul let match; if ((match = lastLine.match(/^(\s*)- \[( |x)\] /))) { // task e.preventDefault(); if (lastLine.trim() === '- [ ]' || lastLine.trim() === '- [x]') { this.removeLastLine(pos, lastLine.length); } else { const insert = '\n' + match[1] + '- [ ] '; this.insertAtCursor(insert); } } else if ((match = lastLine.match(/^(\s*)([-*+] )/))) { // ul e.preventDefault(); if (lastLine.trim() === match[2].trim()) { this.removeLastLine(pos, lastLine.length); } else { const insert = '\n' + match[1] + match[2]; this.insertAtCursor(insert); } } else if ((match = lastLine.match(/^(\s*)(\d+)\. /))) { // ol e.preventDefault(); if (lastLine.trim() === `${match[2]}.`) { this.removeLastLine(pos, lastLine.length); } else { const nextNum = parseInt(match[2]) + 1; const insert = `\n${match[1]}${nextNum}. `; this.insertAtCursor(insert); } } } }); } removeLastLine(cursorPos, lengthToRemove) { const start = cursorPos - lengthToRemove; this.textarea.setRangeText('', start, cursorPos, 'start'); this.textarea.setSelectionRange(start, start); } insertAtCursor(text) { const start = this.textarea.selectionStart; const end = this.textarea.selectionEnd; this.textarea.setRangeText(text, start, end, 'end'); const newPos = start + text.length; this.textarea.setSelectionRange(newPos, newPos) this.markUnsaved(); } captureInitialContent() { const hidden = document.createElement('script'); hidden.type = 'text/plain'; // hidden.style.display = 'none'; hidden.classList.add('tp-md-initial'); hidden.textContent = this.wrapper.textContent.trim(); // clear inner content so it's not visible twice this.wrapper.textContent = ''; this.wrapper.appendChild(hidden); this.textarea.value = hidden.textContent; } static defaultButtons() { return { h1: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '# ', 'H1', 'fas fa-heading', '', 'fas fa-1'); } }, h2: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '## ', 'H2', 'fas fa-heading', '', 'fas fa-2'); } }, h3: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '### ', 'H3', 'fas fa-heading', '', 'fas fa-3'); } }, bold: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '**', 'Bold', 'fas fa-bold', '**'); } }, italic: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '_', 'Italic', 'fas fa-italic', '_'); } }, quote: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '> ', 'Quote', 'fas fa-quote-right'); } }, code: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '`', 'Code', 'fas fa-code', '`'); } formatSelection(sel) { if (!sel || !sel.includes('\n')) { return '`' + (sel || 'code') + '`'; } return '```\n' + sel + '\n```'; } }, link: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '[', 'Link', 'fas fa-link', '](url)'); } formatSelection(sel) { return `[${sel || 'text'}](url)`; } }, bullet: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '- ', 'Bullet', 'fas fa-list-ul'); } formatSelection(sel){ return sel.split('\n').map(line => '- ' + line).join('\n'); } }, number: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '1. ', 'Numbered', 'fas fa-list-ol'); } formatSelection(sel){ return sel.split('\n').map((line, i) => `${i+1}. ${line}`).join('\n'); } }, task: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '- [ ] ', 'Task', 'fas fa-tasks'); } formatSelection(sel){ return sel.split('\n').map(line => ' - [ ]' + line).join('\n') } }, hr: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '---\n', 'HR', 'fas fa-minus'); } }, table: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '', 'Table', 'fas fa-table'); } formatSelection(_) { return '| Col1 | Col2 |\n|------|------|\n| Val1 | Val2 |'; } }, }; } } class TPMarkdownButton { constructor(textarea, prefix = '', title = '', icon = '', suffix = '', icon_offset = '') { this.textarea = textarea; this.prefix = prefix; this.suffix = suffix; this.element = document.createElement('tp-md-toolbar-button'); this.element.title = title; if (icon_offset == '') { this.element.innerHTML = ``; } else { this.element.innerHTML = ``; } this.element.addEventListener('click', () => this.apply()); } formatSelection(sel) { return this.prefix + (sel || 'text') + this.suffix; } apply() { const textarea = this.textarea; const start = textarea.selectionStart; const end = textarea.selectionEnd; const text = textarea.value; this.previousValue = textarea.value; const selected = text.substring(start, end); const formatted = this.formatSelection(selected); textarea.setRangeText(formatted, start, end, 'end'); if(this.previousValue !== textarea.value){ if(!textarea.undoStack) textarea.undoStack = []; textarea.undoStack.push(this.previousValue); } textarea.focus(); } } // Export as global window.tp_md_editor = TPMarkdownEditor; --- End File: public/js/tp_md_editor.js --- --- File: scss/main.scss --- // import bulma @use "vendor/bulma"; // import custom components @use "components/ticket-item"; --- End File: scss/main.scss --- --- File: scss/components/_ticket-item.scss --- @use "../vendor/bulma"; .ticket-item { @extend .is-flex; @extend .mb-1; @extend .pt-1; @extend .pb-2; @extend .is-align-items-flex-start; border-bottom: 1px solid var(--bulma-text-90); .ticket-icon { @extend .mr-2; display: flex; align-items: baseline; .checkbox { margin-right: 0.5rem; } } .ticket-content { @extend .is-flex; @extend .is-flex-direction-column; @extend .is-flex-grow-1; align-self: baseline; .ticket-header { @extend .is-flex; @extend .is-justify-content-flex-start; @extend .is-flex-wrap-wrap; @extend .mb-1; align-items: center; .ticket-title { @extend .title; @extend .mb-0; @extend .is-5; font-weight: normal; } .tags { @extend .ml-2; } } .ticket-meta { @extend .is-flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; p { @extend .subtitle; @extend .is-6; font-weight: 300; margin: 0; } } } } --- End File: scss/components/_ticket-item.scss --- --- File: scss/vendor/_bulma-tools.scss --- @use "../../node_modules/bulma/sass/utilities/" as bulma-utils; @use "../../node_modules/bulma/sass/helpers/" as bulma-helpers; @use "../../node_modules/bulma/sass/elements/" as bulma-elements; --- End File: scss/vendor/_bulma-tools.scss --- --- File: scss/vendor/_bulma.scss --- @forward "../../node_modules/bulma/bulma"; --- End File: scss/vendor/_bulma.scss --- ============================================================ End of Codebase ============================================================