Compare commits

..

10 Commits

27 changed files with 1240 additions and 112 deletions

3
.gitignore vendored
View File

@ -2,4 +2,5 @@
/downloads/
/app/.env.cfg
/public/tmp/
composer.lock
composer.lock
/storage/

View File

@ -0,0 +1,152 @@
<?php
class AttachmentController {
private function check_access($f3){
if(!$f3->exists('SESSION.user')){
$f3->reroute('/login');
}
}
// list attachments
public function index($f3){
$this->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', '../ui/views/attachment/index.html');
// echo \Template::instance()->render('../ui/templates/layout.html');
echo \Template::instance()->render($f3->get('content'));
}
// handle file upload
public function upload($f3){
$this->check_access($f3);
$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.'');
}
// 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 File

@ -4,6 +4,7 @@ class AuthController {
public function showLoginForm($f3){
// store session errors or messages, then clear
$f3->set('error', $f3->get('SESSION.login_error'));
$f3->clear('SESSION.login_error');
@ -21,7 +22,11 @@ class AuthController {
$db = $f3->get('DB');
// query for user
$result = $db->exec(
'SELECT id, username, password, role FROM users WHERE username =? LIMIT 1', $username
'SELECT u.id, u.username, u.password, u.role, r.role as role_name
FROM users u
LEFT JOIN roles r ON r.id = u.role
WHERE username =?
LIMIT 1', $username
);
// verifiy password
@ -31,7 +36,9 @@ class AuthController {
// valid
$f3->set('SESSION.user', [
'id'=> $user['id'],
'username' => $user['username']
'username' => $user['username'],
'role' => $user['role'],
'role_name' => $user['role_name']
]);
$f3->reroute('/dashboard');

View File

@ -0,0 +1,91 @@
<?php
class CommentController {
/**
* Add a new comment to a ticket.
* Expects POST data: comment (text)
* Route: POST /ticket/@id/comment
*/
public function create($f3){
// check logged in
if(!$f3->exists('SESSION.user')){
$f3->reroute('/login');
}
$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('../ui/views/comments/view.html');
}
}

View File

@ -154,6 +154,7 @@ class KBController {
$f3->set('article_tag_ids', $article_tag_ids);
// render
$f3->set('js', 'kb_edit.js');
$f3->set('content', '../ui/views/kb/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->clear('SESSION.error');

View File

@ -0,0 +1,12 @@
<?php
class ParsedownPreview {
public function view($f3){
$preview_text = $f3->get('POST.content');
echo Parsedown::instance()->text($preview_text);
}
}

View File

@ -2,13 +2,6 @@
class TicketController {
protected function check_access($f3){
if(!$f3->exists('SESSION.user')){
// $f3->set('SESSION.error', 'You don\'t have permission for this ticket.');
$f3->reroute('/login');
}
}
// list all tickts
public function index($f3){
$this->check_access($f3);
@ -35,24 +28,13 @@ class TicketController {
$ticket_id = $f3->get('PARAMS.id');
$db = $f3->get('DB');
$result = $db->exec(
'SELECT t.*, u.username as created_by_name
FROM tickets t
LEFT JOIN users u ON t.created_by = u.id
WHERE t.id =? LIMIT 1',
[$ticket_id]
);
if(!$result){
// no record
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
}
$ticket = $result[0];
$f3->set('ticket', $ticket);
$this->get_ticket($f3, $db, $ticket_id);
$this->get_child_tickets($f3, $db, $ticket_id);
$this->get_parent_tickets($f3, $db, $ticket_id);
$this->get_custom_fields($f3, $db, $ticket_id);
// render
$f3->set('js', 'ticket_view.js');
$f3->set('content', '../ui/views/ticket/view.html');
echo \Template::instance()->render('../ui/templates/layout.html');
@ -66,6 +48,7 @@ class TicketController {
}
// handle POST
// including custom forms
public function create($f3){
$this->check_access($f3);
@ -84,39 +67,31 @@ class TicketController {
[$title, $description, $priority, $status, $created_by]
);
$f3->reroute('/tickets');
}
$ticket_id = $db->lastInserId();
protected function get_ticket_check_edit_permission($f3){
// custom fields
$meta_keys = $f3->get('POST.meta_key'); // eg ['department', 'category']
$meta_values = $f3->get('POST.meta_value');
$db = $f3->get('DB');
$ticket_id = $f3->get('PARAMS.id');
$result = $db->exec('SELECT * FROM tickets WHERE id = ? LIMIT 1', [$ticket_id]);
if(!$result){
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
if(is_array($meta_keys) && is_array($meta_values)){
foreach($meta_keys as $index => $key){
$val = $meta_values[$index] ?? '';
if(!empty($key) && $val !== ''){
$db->exec(
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
VALUES (?,?,?)',
[$ticket_id, $key, $val]
);
}
}
}
$ticket = $result[0];
// TODO: refine
$current_user = $f3->get('SESSION.user');
$is_admin = (isset($current_user['role']) && $current_user['role'] == 'admin');
$is_assigned = ($ticket['assigned_to'] == $current_user['id']);
if(!$is_admin && !$is_assigned){ // should this be ||
// if not assigned and not admin, disallow edit
$f3->set('SESSION.error', 'You do not have permission to edit this ticket.');
$f3->reroute('/tickets');
}
return $ticket;
// reroute to ticket
$f3->reroute('/ticket/'.$ticket_id);
}
// show edit form
// including custom forms
public function editForm($f3){
$this->check_access($f3);
@ -127,6 +102,15 @@ class TicketController {
$ticket = $this->get_ticket_check_edit_permission($f3);
$f3->set('ticket', $ticket);
// fetch custom fields
$meta = $db->exec(
'SELECT id, meta_key, meta_value
FROM ticket_meta
WHERE ticket_id = ?',
[$ticket_id]
);
$f3->set('ticket_meta', $meta);
$f3->set('ticket', $ticket);
$f3->set('content', '../ui/views/ticket/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
@ -155,7 +139,163 @@ class TicketController {
[$title, $description, $priority, $status, $updated_by, 'NOW()', $ticket_id]
);
// handle custom fields
// 1. update existing custom fields
$meta_ids = $f3->get('POST.meta_id');
$meta_keys = $f3->get('POST.meta_key');
$meta_values = $f3->get('POST.meta_value');
if(is_array($meta_ids) && is_array($meta_keys) && is_array($meta_values)){
foreach($meta_ids as $idx => $m_id){
$m_key = $meta_keys[$idx] ?? '';
$m_val = $meta_values[$idx] ?? '';
if(!empty($m_key) && $m_val !== ''){
$db->exec(
'UPDATE ticket_meta
SET meta_key = ?, meta_value=?
WHERE id = ? AND ticket_id = ?',
[$m_key, $m_val, $m_id, $ticket_id]
);
} else {
// delete if the user cleared it
$db->exec(
'DELETE FROM ticket_meta
WHERE id =? AND ticket_id = ?',
[$m_id, $ticket_id]
);
}
}
}
// 2. insert new fields
$new_keys = $f3->get('POST.new_meta_key');
$new_values = $f3->get('POST.new_meta_value');
if(is_array($new_keys) && is_array($new_values)){
foreach($new_keys as $idx => $n_key){
$n_val = $new_values[$idx] ?? '';
if(!empty($n_key) && $n_val !== ''){
$db->exec(
'INSERT INTO ticket_meta (ticket_id, meta_key, meta_value)
VALUES (?,?,?)',
[$ticket_id, $n_key, $n_val]
);
}
}
}
$f3->reroute('/ticket/' . $ticket_id);
}
// subtask
public function addSubtask($f3){
$this->check_access($f3);
$parent_id = (int) $f3->get('PARAMS.id');
$child_id = (int) $f3->get('POST.child_ticket_id');
$db = $f3->get('DB');
// TODO check that both parent and child tickets exist
// ensure you don't link a ticket to itself, etc.
// insert or ignore
$db->exec(
'INSERT IGNORE INTO ticket_relations (parent_ticket_id, child_ticket_id)
VALUES (?, ?)',
[$parent_id, $child_id]
);
$f3->reroute('/ticket/' . $parent_id);
}
protected function check_access($f3){
if(!$f3->exists('SESSION.user')){
// $f3->set('SESSION.error', 'You don\'t have permission for this ticket.');
$f3->reroute('/login');
}
}
protected function get_ticket_check_edit_permission($f3){
$db = $f3->get('DB');
$ticket_id = $f3->get('PARAMS.id');
$result = $db->exec('SELECT * FROM tickets WHERE id = ? LIMIT 1', [$ticket_id]);
if(!$result){
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
}
$ticket = $result[0];
// TODO: refine
$current_user = $f3->get('SESSION.user');
$is_admin = (isset($current_user['role_name']) && $current_user['role_name'] == 'admin');
$is_assigned = ($ticket['assigned_to'] == $current_user['id']);
if(!$is_admin && !$is_assigned){ // should this be ||
// if not assigned and not admin, disallow edit
$f3->set('SESSION.error', 'You do not have permission to edit this ticket.');
$f3->reroute('/tickets');
}
return $ticket;
}
protected function get_ticket($f3, $db, $ticket_id){
$result = $db->exec(
'SELECT t.*, u.username as created_by_name
FROM tickets t
LEFT JOIN users u ON t.created_by = u.id
WHERE t.id =? LIMIT 1',
[$ticket_id]
);
if(!$result){
// no record
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
}
$ticket = $result[0];
$f3->set('ticket', $ticket);
}
protected function get_child_tickets($f3, $db, $ticket_id){
$child_tickets = $db->exec(
'SELECT c.*
FROM ticket_relations r
INNER JOIN tickets c ON r.child_ticket_id = c.id
WHERE r.parent_ticket_id = ?',
[$ticket_id]);
$f3->set('child_tickets', $child_tickets);
}
protected function get_parent_tickets($f3, $db, $ticket_id){
$parent_tickets = $db->exec(
'SELECT p.*
FROM ticket_relations r
INNER JOIN tickets p ON r.parent_ticket_id = p.id
WHERE r.child_ticket_id = ?',
[$ticket_id]
);
$f3->set('parent_tickets', $parent_tickets);
}
protected function get_custom_fields($f3, $db, $ticket_id){
$meta = $db->exec(
'SELECT meta_key, meta_value
FROM ticket_meta
WHERE ticket_id = ?',
[$ticket_id]
);
// convert meta rows into assoc array
$meta_array = [];
foreach($meta as $m){
$meta_array[$m['meta_key']] = $m['meta_value'];
}
$f3->set('ticket_meta', $meta_array);
}
}

View File

@ -0,0 +1,62 @@
<?php
class UserController {
// list all users (admin only)
protected function check_access($f3){
$current_user = $f3->get('SESSION.user');
if(!$current_user || $current_user['role_name'] !== 'admin'){
$f3->reroute('/login');
}
}
public function index($f3){
$this->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', '../ui/views/user/index.html');
echo \Template::instance()->render('../ui/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', '../ui/views/user/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
}
public function update($f3){
$this->check_access($f3);
$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');
}
}

View File

@ -0,0 +1,108 @@
<?php
class BulmaFormHelper extends \Prefab {
const H_FIELD_INPUT = 1;
const H_FIELD_TEXTAREA = 2;
const H_FIELD_SELECT = 3;
static public function render($args) {
$type = strtoupper($args['@attrib']['type']);
// all *
$label = $args['@attrib']['label'];
$name = $args['@attrib']['name'];
$value = isset($args['@attrib']['value']) ? $args['@attrib']['value'] : '';
// select
$options = isset($args['@attrib']['options']) ? $args['@attrib']['options'] : '';
$selected = isset($args['@attrib']['selected']) ? $args['@attrib']['selected'] : '';
//
$label = \Template::instance()->build($label);
$name = \Template::instance()->build($name);
$value = \Template::instance()->build($value);
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;
default:
return '<div class="notification is-danger">Error: Bulma CSS Form TYPE ('.$type.') not defined.</div>';
break;
}
} else {
return '<div class="notification is-danger">Error: Bulma CSS Form TYPE not defined.</div>';
}
}
static function build_h_field_input($label, $name, $value){
$string = '
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">'.$label.'</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input" type="text" id="'.$name.'" name="'.
$name.'" value="'.
$value.'">
</div>
</div>
</div>
</div>
';
return $string;
}
static function build_h_field_select($label, $name, $options, $selected){
$string =
'<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">'.$label.'</label>
</div>
<div class="field-body">
<div class="field">
<div class="select">
$options
</div>
</div>
</div>
</div>
';
return $string;
}
static function build_h_field_textarea($label, $name, $value){
$string = '
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">'.$label.'</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<textarea class="textarea" id="'.$name.'" name="'.
$name.'">'.$value.'</textarea>
</div>
</div>
</div>
</div>
';
return $string;
}
}
\Template::instance()->extend('bulma', 'BulmaFormHelper::render');

View File

@ -0,0 +1,20 @@
<?php
class ParsedownHelper extends \Prefab {
static public function render($args) {
$content = $args[0];
$content_token = \Template::instance()->token($content);
return '
<parsedown_rendered class="content">
<?php echo \ParsedownHelper::instance()->build('.$content_token.'); ?>
</parsedown_rendered>';
}
function build($content){
return Parsedown::instance()->text($content);
}
}
\Template::instance()->extend('parsedown', 'ParsedownHelper::render');

View File

@ -8,7 +8,7 @@ class BulmaForm {
$string = '
<div class="field is-horizontal">
<div class="field-label is-normal">
<label>%label%</label>
<label class="label">%label%</label>
</div>
<div class="field-body">
<div class="field">
@ -31,7 +31,7 @@ class BulmaForm {
$string = '
<div class="field is-horizontal">
<div class="field-label is-normal">
<label>%label%</label>
<label class="label">%label%</label>
</div>
<div class="field-body">
<div class="field">
@ -54,7 +54,7 @@ class BulmaForm {
$string = '
<div class="field is-horizontal">
<div class="field-label is-normal">
<label>%label%</label>
<label class="label">%label%</label>
</div>
<div class="field-body">
<div class="field">

View File

@ -6,13 +6,16 @@ $f3 = \Base::instance();
/**
* Not required yet
*/
// $htmlpurifier = new \HTMLPurifier();
$htmlpurifier = \HTMLPurifier::instance();
// $htmlpurifier->purify($input);
$md = \Parsedown::instance();
$md = \Parsedown::instance();
$md->setSafeMode(true);
$f3->set('DEBUG', 3); // development debug
$f3->config('../app/.env.cfg');
$f3->set('DEBUG', 3); // development debug
$f3->set('CACHE', FALSE);
$f3->set('EXT', [new ParsedownHelper, new BulmaFormHelper]);
$f3->set('DB', new \DB\SQL(
'mysql:host=localhost;port=3306;dbname=' . $f3->get('database.db_name'),
@ -49,6 +52,18 @@ $f3->route('GET /ticket/create', 'TicketController->createForm'); // show form t
$f3->route('POST /ticket/create', 'TicketController->create'); // save
$f3->route('GET /ticket/@id/edit', 'TicketController->editForm'); // edit ticket
$f3->route('POST /ticket/@id/update', 'TicketController->update'); //
// additional routes - comments
$f3->route('POST /ticket/@id/comment', 'CommentController->create');
$f3->route('GET /ticket/@id/comment/@comment_id/delete', 'CommentController->delete');
$f3->route('GET /ticket/@id/comments', 'CommentController->index');
// route for linking a child to a parent
$f3->route('POST /ticket/@id/add-subtask', 'TicketController->addSubtask');
// attachments
$f3->route('GET /ticket/@id/attachments', 'AttachmentController->index');
$f3->route('POST /ticket/@id/attachments/upload', 'AttachmentController->upload');
$f3->route('GET /attachment/@id/download', 'AttachmentController->download');
$f3->route('GET /attachment/@id/delete', 'AttachmentController->delete');
// knowledgebase
$f3->route('GET /kb', 'KBController->index');
@ -63,8 +78,16 @@ $f3->route('GET /tags', 'TagController->index');
$f3->route('GET /tag/create', 'TagController->createForm');
$f3->route('POST /tag/create', 'TagController->create');
// parsedown preview
$f3->route('POST /parsedown/preview', 'ParsedownPreview->view');
// dashboard
$f3->route('GET /dashboard', 'DashboardController->index');
// additional routes
$f3->route('GET /users', 'UserController->index');
$f3->route('GET /user/@id/edit', 'UserController->editForm');
$f3->route('POST /user/@id/update', 'UserController->update');
$f3->run();

75
public/js/kb_edit.js Normal file
View File

@ -0,0 +1,75 @@
// 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<tabLinks.length; i++){
tabLinks[i].addEventListener('click', function(e){
e.preventDefault();
var target = this.getAttribute('data-target');
switchTab(target);
// if the new tab has a preview element, load the preview
var pane = document.getElementById(target);
if(pane){
var previewElement = pane.querySelector('.preview');
if(previewElement){
console.log('pane has preview el')
loadPreview(previewElement);
} else {
console.log('pane doesnt have preview el');
}
}
});
}
}
document.addEventListener('DOMContentLoaded', function(){
initTabs();
});

30
public/js/ticket_view.js Normal file
View File

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

View File

@ -3,4 +3,123 @@ html, body, #sidebar, #page,#base_body {
min-height: 100%
}
#page { min-height: calc(100vh - 170px - 52px) }
#page { min-height: calc(100vh - 170px - 52px) }
.table th.th-icon { width: 2rem; }
/* 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
}
}

View File

@ -8,16 +8,19 @@
<!-- bulma.io-->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<!-- bulma helpers -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma-helpers/0.4.3/css/bulma-helpers.min.css"
integrity="sha512-U6ELnUi7oqVEjkLmFw5r5UR5LEtvpImS/jUykBKneVhD0lxZxfJZ3k3pe003ktrtNZYungd9u3Urp2X09wKwXg=="
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma-helpers/0.4.3/css/bulma-helpers.min.css"
integrity="sha512-U6ELnUi7oqVEjkLmFw5r5UR5LEtvpImS/jUykBKneVhD0lxZxfJZ3k3pe003ktrtNZYungd9u3Urp2X09wKwXg=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma-checkradio@2.1/dist/css/bulma-checkradio.min.css">
<!-- font awesome -->
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- additional JS -->
<check if="{{ isset(@js) }}">
<script src="/js/{{ @js}}"></script>
</check>
</head>
<body>
@ -85,24 +88,27 @@
<!-- Footer -->
<footer class="footer">
<div class="content has-text-centered">
<p>&copy; <?php echo date('Y'); ?> Terry Probert</p>
<p>&copy;
<?php echo date('Y'); ?> Terry Probert
</p>
</div>
</footer>
<!-- JavaScript for Bulma navbar burger (mobile) -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const burgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
if (burgers.length > 0) {
burgers.forEach(el => {
el.addEventListener('click', () => {
const target = document.getElementById(el.dataset.target);
el.classList.toggle('is-active');
target.classList.toggle('is-active');
<!-- JavaScript for Bulma navbar burger (mobile) -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const burgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
if (burgers.length > 0) {
burgers.forEach(el => {
el.addEventListener('click', () => {
const target = document.getElementById(el.dataset.target);
el.classList.toggle('is-active');
target.classList.toggle('is-active');
});
});
});
}
});
</script>
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,48 @@
<div class="box">
<div class="content">
<h2 class="title">Attachments</h2>
<div class="block">
<check if="isset( {{@attachments }})">
<table class="table is-fullwidth is-narrow is-striped is-hoverable">
<thead>
<tr>
<th class="th-icon"></th>
<th>File Name</th>
<th>Uploaded By</th>
<th>Created At</th>
<th>Version</th>
</tr>
</thead>
<tbody>
<repeat group="{{ @attachments }}" value="{{ @attach }}">
<tr>
<td>
<span class="icon"><i class="fas fa-file"></i></span>
</td>
<td><a href="/attachment/{{@attach.id}}/download">{{ @attach.file_name }}</a></td>
<td>{{ @attach.username }}</td>
<td>{{ @attach.created_at }}</td>
<td>{{ @attach.version_number }}</td>
</tr>
</repeat>
</tbody>
</table>
</check>
<div class="block">
<h3 class="title">Upload attachment</h3>
<form action="/ticket/{{@ticket_id}}/attachments/upload" method="POST" enctype="multipart/form-data">
<div class="field has-addons">
<div class="control has-icons-left"><!-- is-expanded -->
<input class="input" type="file" name="attachment" required>
<span class="icon is-small is-left">
<i class="fas fa-file"></i>
</span>
</div>
<div class="control">
<button class="button" type="submit">Upload</button>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,41 @@
<div class="box" id="comments">
<h2 class="title">Comments</h2>
<div class="block">
<form action="/ticket/{{@PARAMS.id}}/comment" method="POST">
<div class="field">
<label class="label">Add comment:</label>
<div class="control">
<textarea class="textarea" name="comment" rows="4" cols="50"></textarea>
</div>
</div>
<div class="field">
<button class="button is-primary" type="submit">Submit Comment</button>
</div>
</form>
</div>
<check if="{{ !empty(@comments) }}">
<div class="list">
<repeat group="{{ @comments }}" value="{{ @comment}}">
<div class="list-item">
<div class="list-item-image">
<figure class="image is-48x48">
<img class="is-rounded"
src="https://placehold.co/200x200/66d1ff/FFF?text=TP">
<!-- <img class="is-rounded"
src="https://loremflickr.com/200/200/dog?{{ (int)rand()}}">-->
</figure>
</div>
<div class="list-item-content">
<div class="list-item-title is-flex is-justify-content-space-between">
<span>{{ @comment.author_name}}</span>
<span class="has-text-weight-normal has-text-grey">{{ @comment.created_at }}</span>
</div>
<div class="list-item-description">
<parsedown>{{ @comment.comment | raw }}</parsedown>
</div>
</div>
</div>
</repeat>
</div>
</check>
</div>

View File

@ -4,18 +4,23 @@
<div class="content">
<form action="/kb/create" method="POST">
{{ BulmaForm::horizontal_field_input('Title:', 'title') }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>
<div id="editor" class="block">
<div class="tabs">
<ul>
<li class="is-active"><a>Write</a></li>
<li class=""><a>Preivew</a></li>
<li class=""><a>Preview</a></li>
</ul>
</div>
<div class="block">
{{ BulmaForm::horizontal_field_textarea('Content:', 'content') }}
<div class="tab-content">
<bulma type="H_FIELD_TEXTAREA" label="Content:" name="content" value=""></bulma>
</div>
<div class="tab-content">
</div>
</div>
</div>

View File

@ -3,18 +3,27 @@
<form action="/kb/{{@article.id}}/update" method="POST">
{{ BulmaForm::horizontal_field_input('Title:', 'title', @article.title)}}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@article.title}}"></bulma>
<div id="editor" class="block">
<div class="tabs">
<ul>
<li class="is-active"><a>Write</a></li>
<li class=""><a>Preivew</a></li>
<li class="is-active"><a data-target="pane-editor">Write</a></li>
<li class=""><a data-target="pane-preview">Preview</a></li>
</ul>
</div>
<div class="block">
{{ BulmaForm::horizontal_field_textarea('Content:', 'content', @article.content) }}
<div class="tab-content">
<div class="tab-pane" id="pane-editor">
<div class="block">
<bulma type="H_FIELD_TEXTAREA" label="Content:" name="content" value="{{@article.content}}"></bulma>
</div>
</div>
<div class="tab-pane" id="pane-preview">
<div class="block content">
<div class="preview" data-source="content" data-handler="/parsedown/preview" data-method="post"></div>
</div>
</div>
</div>
</div>

View File

@ -13,7 +13,15 @@
<form method="GET" action="/kb">
<div class="level">
<div class="level-item">
<input class="input" type="text" name="search" placeholder="Search by title...">
<check if="{{ isset(@GET.search)}}">
<true>
<input class="input" type="text" name="search" placeholder="Search by title..."
value="{{ HTMLPurifier::instance()->purify( @GET.search) }}">
</true>
<false>
<input class="input" type="text" name="search" placeholder="Search by title...">
</false>
</check>
</div>
<div class="level-right">
<div class="select">

View File

@ -1,7 +1,7 @@
<h1 class="title">{{@article.title}}</h1>
<div class="content">
{{ Parsedown::instance()->text(@article.content) }}
<parsedown>{{ @article.content | raw }}</parsedown>
</div>

View File

@ -3,14 +3,37 @@
<form action="/ticket/create" method="POST">
{{ BulmaForm::horizontal_field_input('Title:', 'title') }}
{{ BulmaForm::horizontal_field_textarea('Description:', 'description') }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value=""></bulma>
{{ BulmaForm::horizontal_field_select('Priority:', 'priority', ['Low', 'Medium', 'High'])}}
{{ BulmaForm::horizontal_field_select('Status:', 'status', ['New', 'In Progress', 'On Hold', 'Completed'])}}
<!-- custom fields -->
<hr>
<div class="block">
<div class="field is-grouped is-grouped-right">
<div class="control">
<label class="label">Key:</label>
<input class="input" type="text" name="meta_key[]" placeholder="eg. Department">
</div>
<div class="control">
<label class="label">Value:</label>
<input class="input" type="text" name="meta_value[]" placeholder="eg. Finance">
</div>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<label class="label">Key:</label>
<input class="input" type="text" name="meta_key[]" placeholder="eg. Category">
</div>
<div class="control">
<label class="label">Value:</label>
<input class="input" type="text" name="meta_value[]" placeholder="eg. Urgent">
</div>
</div>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<a class="button is-secondary" href="/tickets">Cancel</a>

View File

@ -2,14 +2,51 @@
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
{{ BulmaForm::horizontal_field_input('Title:', 'title', @ticket.title) }}
{{ BulmaForm::horizontal_field_textarea('Description:', 'description', @ticket.description) }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@ticket.title}}"></bulma>
<bulma type="H_FIELD_TEXTAREA" label="Description:" name="description" value="{{@ticket.description}}"></bulma>
{{ BulmaForm::horizontal_field_select('Priority:', 'priority', ['Low', 'Medium', 'High'])}}
{{ BulmaForm::horizontal_field_select('Status:', 'status', ['New', 'In Progress', 'On Hold', 'Completed'])}}
<button class="button is-primary" type="submit">Edit Ticket</button>
<div class="block">
<h3 class="title is-5">Custom Fields</h3>
<!-- existing fields-->
<div class="block">
<repeat group="{{ @ticket_meta }}" value="{{ @m }}">
<div class="field is-grouped is-grouped-right">
<input type="hidden" name="meta_id[]" value=" {{ @m.id }}">
<div class="control">
<label class="label">Key:</label>
<input class="input" type="text" name="meta_key[]" value="{{ @m.meta_key }}"
placeholder="eg. Department">
</div>
<div class="control">
<label class="label">Value:</label>
<input class="input" type="text" name="meta_value[]" value="{{ @m.meta_value }}"
placeholder="eg. Finance">
</div>
</div>
</repeat>
</div>
<hr>
<!-- adding new custom meta -->
<div class="block">
<div class="field is-grouped is-grouped-right">
<div class="control">
<label class="label">Key:</label>
<input class="input" type="text" name="new_meta_key[]"
placeholder="eg. Department">
</div>
<div class="control">
<label class="label">Value:</label>
<input class="input" type="text" name="new_meta_value[]"
placeholder="eg. Finance">
</div>
</div>
</div>
</div>
<button class="button is-primary" type="submit">Save Ticket</button>
</div>
</form>

View File

@ -1,16 +1,84 @@
<h1 class="title">Ticket - View</h1>
<!-- Ticket - View -->
<h1 class="title">{{ @ticket.title }}</h1>
<p><a href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
<hr>
<div class="box">
<div class="content">
<table class="table is-bordered is-fullwidth">
<thead>
<tr><th class="has-width-200">Property</th><th>Value</th></tr>
</thead>
<tbody>
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
</repeat>
</tbody>
</table>
<div class="columns">
<div class="column is-two-thirds">
<div class="block">
<p>{{ @ticket.created_at }}</p>
<div class="content">
<parsedown>{{ @ticket.description | raw }}</parsedown>
</div>
</div>
</div>
<div class="column">
<!-- meta data -->
<div class="block">
<table class="table is-bordered is-fullwidth">
<thead>
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
</thead>
<tbody>
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
<check if="{{ @key !== 'description'}}">
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
</check>
</repeat>
</tbody>
</table>
</div>
<!-- form to add child ticket relationships -->
<div class="box">
<h3>Linked Tickets</h3>
<!-- parent -->
<check if="{{ @parent_tickets }}">
<div class="block">
<h4 class="title">Parent Tickets</h4>
<ul>
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
</repeat>
</ul>
</div>
</check>
<!-- child tickets -->
<check if="{{ @child_tickets }}">
<div class="block">
<h4 class="title">Child Tickets</h4>
<ul>
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
</repeat>
</div>
</check>
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
<div class="field">
<label class="label">Add existing ticket as child ticket (ID):</label>
<div class="control">
<input class="input" type="number" placeholder="Child Ticket ID" required
name="child_ticket_id">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-link" type="submit">Link</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<hr>
<div class="block" id="attachments"></div>
<div class="block" id="comments"></div>
</div>

24
ui/views/user/edit.html Normal file
View File

@ -0,0 +1,24 @@
<form method="POST" action="/user/{{@edit_user.id}}/update">
<div class="field">
<label class="label">Username</label>
<div class="control">
<input class="input" name="username" type="text" value="{{ @edit_user.username}}">
</div>
</div>
<div class="field">
<label class="label">Role</label>
<div class="select">
<select class="select" name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Edit User</button>
</div>
</div>
</form>

18
ui/views/user/index.html Normal file
View File

@ -0,0 +1,18 @@
<div class="block">
<h1 class="title">All Users</h1>
<table class="table table-bordered is-fullwidth">
<tr>
<th>ID</th><th>Username</th><th>Role</th><th>Actions</th>
</tr>
<repeat group="{{ @users }}" value="{{ @u }}">
<tr>
<td>{{ @u.id }}</td>
<td>{{ @u.username }}</td>
<td>{{ @u.role_name }} ( {{ @u.role }} )</td>
</tr>
</repeat>
</table>
</div>