Keyboard-navigation ++

templating-wip
Eirik Th S 2022-10-30 22:50:11 +01:00
parent 370055e3cc
commit 2fe526da09
14 changed files with 397 additions and 105 deletions

13
config/config.sample.ini Normal file
View File

@ -0,0 +1,13 @@
[system]
template=default
projectRoot=
debug=true
[security]
pepper=IAmBadAtSecurity
[database]
host=localhost
user=
pass=
database=

View File

@ -4,6 +4,15 @@
define("DATABASE", Config::get('database', "database"));
const OBLIGATORY = 0x01;
abstract class DataTypes {
const TEXT = 0x01;
const INTEGER = 0x02;
const BOOLEAN = 0x03;
const DOUBLE = 0x04;
}
abstract class Model {
protected static $database = DATABASE;
protected static $table;
@ -15,15 +24,40 @@ abstract class Model {
public function __construct($id = 0){
foreach (static::$fields as $field => $type){
switch ($type){
case "INT":
$this->{$field} = 0;
break;
case "VARCHAR":
case "TEXT":
default:
$this->{$field} = "";
break;
if(is_array($type)){
$datatype = $type[0];
$obligatory = isset($type[1]) && $type[1] == OBLIGATORY;
// Skip default-value if field is not obligatory
if(!$obligatory){ continue; }
switch ($datatype){
case DataTypes::INTEGER:
case DataTypes::BOOLEAN:
$this->$field = 0;
break;
case DataTypes::DOUBLE:
$this->$field = 0.00;
break;
case DataTypes::TEXT:
default:
$this->$field = '';
break;
}
}
else {
// DEPRECATED METHOD:
switch ($type){
case "INT":
$this->{$field} = 0;
break;
case "VARCHAR":
case "TEXT":
default:
$this->{$field} = "";
break;
}
}
}
@ -46,9 +80,9 @@ abstract class Model {
} catch (DatabaseException $e) {
$this->errors[] = $e;
}
}
$this->initialValues = $this->asArray();
$this->initialValues = $this->asArray();
}
}
/**
@ -158,19 +192,26 @@ abstract class Model {
$newFields = [];
$newValues = [];
$allExceptFirstFields = [];
$allExceptFirstValues = [];
$allExceptFirstField = [];
$allExceptFirstValue = [];
$fieldCount = 0;
foreach(static::$fields as $field => $type){
$obligatory = !is_array($type) || isset($type[1]) && $type[1] == OBLIGATORY;
// Skip non-obligatory fields if they are not set.
if(is_array($type) && !isset($this->{ $field }) && !$obligatory){
continue;
}
$currValue = $this->{ $field };
if(!empty($this->initialValues) && $currValue != $this->initialValues[$field]){
if(!empty($this->initialValues) && (!$obligatory || $currValue != $this->initialValues[$field])){
$newFields[] = $field;
$newValues[] = $currValue;
}
if($fieldCount > 0){
$allExceptFirstFields[] = $field;
$allExceptFirstValues[] = $currValue;
$allExceptFirstField[] = $field;
$allExceptFirstValue[] = $currValue;
}
$fieldCount++;
@ -186,12 +227,12 @@ abstract class Model {
$updateSets[] = "`$field` = ?";
}
DB::query(sprintf('UPDATE %s.%s SET %s WHERE %s LIMIT 1', static::$database, static::$table, implode(', ', $updateSets), "`" . array_keys(static::$fields)[0] . "` = ?"), array_merge($newValues, [$this->{array_keys(static::$fields)[0]}]));
DB::queryPreview(sprintf('UPDATE %s.%s SET %s WHERE %s LIMIT 1', static::$database, static::$table, implode(', ', $updateSets), "`" . array_keys(static::$fields)[0] . "` = ?"), array_merge($newValues, [$this->{array_keys(static::$fields)[0]}]));
}
else {
$sql = sprintf('INSERT INTO %s.%s (%s) VALUE (%s)', static::$database, static::$table, implode(', ', $newFields), implode(', ', array_fill(0, count($newFields), '?')));
elseif(!empty($allExceptFirstValue)){
$sql = sprintf('INSERT INTO %s.%s (%s) VALUE (%s)', static::$database, static::$table, implode(', ', $allExceptFirstField), implode(', ', array_fill(0, count($allExceptFirstField), '?')));
DB::query($sql, $newValues);
DB::query($sql, $allExceptFirstValue);
$insertid = DB::insert_id();
$this->{ array_keys(static::$fields)[0] } = $insertid;
$this->isLoaded = true;
@ -201,6 +242,14 @@ abstract class Model {
return true;
}
public function delete(){
if($this->isLoaded()){
Utils::debug($this);
exit;
$sql = "DELETE FROM ";
}
}
public function asArray(): array {
$arr = [];
foreach ($this as $key => $val){

View File

@ -12,19 +12,9 @@ class PlanStore extends Model {
"state" => [ 'planning', 'shopping', 'closed' ]
];
public static function getUserSpaces(){
$spaces = array_merge(
static::get([ 'owner_id' => Auth::currentUserId() ]),
PlanSpaceMember::get([ 'member_id' => Auth::currentUserId() ])
);
public $items = [];
foreach ($spaces as $s){
if($s->space_name == ""){
$spaceOwner = User::get(['user_id' => $s->owner_id])[0];
$s->space_name = $spaceOwner->full_name != "" ? sprintf('%ss space', $spaceOwner->full_name ) : "A users space";;
}
}
return $spaces;
public function getItems(){
$this->items = PlanStoreItem::get(['plan_store_id' => $this->plan_store_id]);
}
}

View File

@ -2,28 +2,39 @@
//namespace models;
class PlanSpace extends Model {
protected static $table = "plan_space";
class PlanStoreItem extends Model {
protected static $table = "plan_store_item";
protected static $fields = [
"space_id" => "INT",
"space_name" => "VARCHAR",
"owner_id" => "INT",
"space_type" => [ 'STORE', 'CHECK', 'CALORIES' ]
"plan_item_id" => [ Datatypes::INTEGER, OBLIGATORY ],
"plan_store_id" => [ Datatypes::INTEGER, OBLIGATORY ],
"pos" => [ Datatypes::INTEGER ],
"name" => [ Datatypes::TEXT, OBLIGATORY ],
"price" => [ Datatypes::DOUBLE, OBLIGATORY ],
"amount" => [ DataTypes::DOUBLE ],
"checked" => [ DataTypes::BOOLEAN ]
];
public static function getUserSpaces(){
$spaces = array_merge(
static::get([ 'owner_id' => Auth::currentUserId() ]),
PlanSpaceMember::get([ 'member_id' => Auth::currentUserId() ])
);
foreach ($spaces as $s){
if($s->space_name == ""){
$spaceOwner = User::get(['user_id' => $s->owner_id])[0];
$s->space_name = $spaceOwner->full_name != "" ? sprintf('%ss space', $spaceOwner->full_name ) : "A users space";;
public function __set($name, $value){
if(in_array($name, array_keys(static::$fields))){
$fieldDesc = static::$fields[$name];
switch ($fieldDesc[0]){
case DataTypes::DOUBLE:
$this->$name = (double) $value;
break;
case DataTypes::INTEGER:
$this->$name = (int) $value;
break;
case DataTypes::BOOLEAN:
$this->$name = (bool) $value;
break;
case DataTypes::TEXT:
default:
$this->$name = $value;
break;
}
}
return $spaces;
else {
$this->$name = $value;
}
}
}

View File

@ -3,7 +3,6 @@
namespace models;
class Product extends Model {
// protected static $database = "i18n";
protected static $table = "product";
protected static $fields = [
"product_id" => "INT",

View File

@ -9,9 +9,9 @@
<link rel='stylesheet' href='{{ pr }}/css/bootstrap.min.css' type='text/css' />
<link rel='stylesheet' href='{{ pr }}/css/index.css' type='text/css' />
<link rel='apple-touch-icon' sizes='180x180' href='{{ pr }}/apple-touch-icon.png'>
<link rel='icon' type='image/png' sizes='32x32' href='{{ pr }}/favicon-32x32.png'>
<link rel='icon' type='image/png' sizes='16x16' href='{{ pr }}/favicon-16x16.png'>
<link rel='apple-touch-icon' sizes='180x180' href='{{ pr }}/favicon/apple-touch-icon.png'>
<link rel='icon' type='image/png' sizes='32x32' href='{{ pr }}/favicon/favicon-32x32.png'>
<link rel='icon' type='image/png' sizes='16x16' href='{{ pr }}/favicon/favicon-16x16.png'>
<link rel='manifest' href='{{ pr }}/site.webmanifest'>
<link rel='mask-icon' href='{{ pr }}/favicon/safari-pinned-tab.svg' color='#5bbad5'>
<meta name='msapplication-TileColor' content='#da532c'>

View File

@ -1,2 +1,2 @@
Redirect Permanent /favicon.ico /favicon.png
Redirect Permanent /favicon.ico /favicon/favicon.ico

View File

@ -27,10 +27,10 @@ abstract class Api {
$this->message = $e;
}
if(empty($this->data)){
$this->success = false;
$this->message = "No data"; // Todo: Specify missing fields in output
}
// if(empty($this->data)){
// $this->success = false;
// $this->message = "No data"; // Todo: Specify missing fields in output
// }
if($this->success){
try {
@ -51,8 +51,8 @@ abstract class Api {
$returns['data'] = $this->result ?? $this->message;
if(DEBUG){
$returns['debug'][] = debug_backtrace();
$returns['debug']["methods"] = $this->methods;
// $returns['debug'][] = debug_backtrace();
// $returns['debug']["methods"] = $this->methods;
}
echo json_encode( $returns );

View File

@ -0,0 +1,59 @@
<?php
class AddStoreItemApi extends Api {
protected $methods = [
"POST" => [
[
"name" => "store_id",
"keyword" => "store_id",
"type" => VERIFY_STRING
],
[
"name" => "name",
"keyword" => "name",
"type" => VERIFY_STRING
],
[
"name" => "price",
"keyword" => "price",
"type" => VERIFY_STRING
],
[
"name" => "amount",
"keyword" => "amount",
"type" => VERIFY_STRING
],
[
"name" => "product_id",
"keyword" => "store_id",
"type" => VERIFY_STRING
]
]
];
/**
* @throws DatabaseException
*/
function execute(){
try {
$storeitem = new PlanStoreItem();
$storeitem->plan_store_id = $this->data['store_id'];
$storeitem->name = $this->data['name'];
$storeitem->price = $this->data['price'] ?? 0;
$storeitem->amount = $this->data['amount'] ?? 1;
$this->result = $storeitem->save();
$this->success = true;
$this->message = "OK";
}
catch (DatabaseException $e){
$this->success = false;
$this->message = "Error: " . $e;
}
}
}
$request = new AddStoreItemApi();

View File

@ -0,0 +1,42 @@
<?php
class AddStoreItemApi extends Api {
protected $methods = [
"POST" => [
[
"name" => "store_id",
"keyword" => "store_id",
"type" => VERIFY_STRING
],
[
"name" => "plan_item_id",
"keyword" => "plan_item_id",
"type" => VERIFY_STRING
]
]
];
/**
* @throws DatabaseException
*/
function execute(){
try {
$storeitem = new PlanStoreItem($this->data['plan_item_id']);
if($storeitem->isLoaded()){
$storeitem->delete();
}
$this->success = true;
$this->message = "OK";
}
catch (DatabaseException $e){
$this->success = false;
$this->message = "Error: " . $e;
}
}
}
$request = new AddStoreItemApi();

View File

@ -24,6 +24,7 @@ class GetStoresApi extends Api {
*/
function execute(){
$stores = PlanStore::get(['space_id' => $this->data['space'] ]);
array_map(function($store){ $store->getItems(); }, $stores);
$this->result = $stores;

View File

@ -74,6 +74,14 @@ body {
background-color: #3331;
}
.pending {
color: #6c757d;
font-weight: lighter;
}
.error {
color: var(--bs-danger);
}
#stores .card-header {
font-weight: bold;
height: 38px;
@ -116,13 +124,13 @@ span.recipeItemAmount::after {
}
.list-group-item:focus-within .itemButtons,
.list-group-item:hover .itemButtons
.list-group-item:focus-within .itemButtons
/*.list-group-item:hover .itemButtons*/
{
height: 45px;
}
ul.storePlanState .list-group-item:focus-within .itemAmountText,
ul.storePlanState .list-group-item:hover .itemAmountText
ul.storePlanState .list-group-item:focus-within .itemAmountText
/*ul.storePlanState .list-group-item:hover .itemAmountText*/
{
height: 0;
overflow: hidden;

View File

@ -6,7 +6,7 @@ require_once '../../Router.php';
header("Content-Type: application/json");
//if(!checkLogin()){
if(Auth::checkLogin()){
if(!Auth::checkLogin()){
returns("Not logged in",2);
}
@ -19,7 +19,7 @@ $returns = [];
foreach([$_GET, $_POST] as $request){
if(!empty($request)){
foreach($request as $key => $value){
if(($data[$key] = filter($value)) === false){
if(($data[$key] = Utils::filter($value)) === false){
print_r($value);
echo "Failed to sanitize: `".$key."`: ".$value." \t-\t type: ".gettype($value)."\n";
}

View File

@ -60,10 +60,9 @@ class Store {
this.selector.find(".addItemForm").on('submit', ev => {
ev.preventDefault();
this.addItem($(ev.target).find('.newItemName').val(), $(ev.target).find('.newItemPrice').val()).done(json => {
this.selector.find('.newItemPrice').val(0);
this.selector.find('.newItemName').val("").focus();
});
this.addItem($(ev.target).find('.newItemName').val(), $(ev.target).find('.newItemPrice').val());
this.selector.find('.newItemPrice').val(0);
this.selector.find('.newItemName').val("").focus();
});
@ -292,10 +291,25 @@ class Store {
return $.ajax();
}
let newItemElement = this.addItemHtml(text, price, 0, amount);
let that = this;
return ajaxReq({ plan: 'addItem', storeID: this.storeID, name: text, price: price, amount: amount })
.done(json => {
return that.addItemHtml(text, price, json['data'], amount);
return qAPI('plan/storeitem/add', 'POST', {
store_id: this.storeID,
name: text,
price: price,
amount: amount
}).done(json => {
console.log(json);
newItemElement.removeClass('pending');
if(json.status){
this.itemsObj[json.result] = { text: text, price: price, itemID: json.result, amount: amount };
newItemElement.data('itemid', json.result);
}
else {
newItemElement.addClass('error');
}
});
}
@ -307,12 +321,26 @@ class Store {
text = insertLinks(text);
if(typeof this.pending === "undefined"){
this.pending = [];
}
let pending = false;
if(itemID === 0){
let nextPendingItem = this.pending.length;
itemID = "p" + nextPendingItem;
this.pending.push(itemID);
pending = true;
}
try {
price = Number(price);
this.itemsObj[itemID] = { text: text, price: price, itemID: itemID, amount: amount, checked: checked };
let pendingClass = pending ? " pending" : "";
let html =
"<li tabindex='0' class='list-group-item draggable"+(checked?' checkedItem':'')+"' id='item_"+itemID+"' data-itemid='"+itemID+"' style='height: 100%; min-width: 200px;'>" +
"<li tabindex='0' class='list-group-item draggable"+(checked?' checkedItem':'') + pendingClass + "' id='item_"+itemID+"' data-itemid='"+itemID+"' style='height: 100%; min-width: 200px;'>" +
" <div style='display: flex;'>" + // draggable='true'
//" <span style='float: left; margin-right: 0px; width: 32px; margin-left: -22px;'><svg xmlns='http://www.w3.org/2000/svg' fill='gray' class='bi bi-grip-vertical' viewBox='0 0 16 16' height='32' width='32'> <path d='M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0z'></path></svg></span>" +
" <div style='flex: 10px; display: none; margin-right: 5px;' class='checkItems'><input type='checkbox'"+(checked?" checked":"")+"></div>" +
@ -320,7 +348,7 @@ class Store {
" <div style='flex: 35%;' class='priceWrapper'>" +
" <span class='price'>"+(price*amount).toFixed(2)+"</span>" +
" <img src='../icon/dash-circle.svg' alt='Remove item' class='remItem ariaButton' data-itemID='"+itemID+"' data-price='"+price+"' style='margin-right: 3px;' tabindex='0' role='button'>" +
" <img src='../icon/dash-circle.svg' alt='Remove item' class='remItem ariaButton' data-price='"+price+"' style='margin-right: 3px;' tabindex='0' role='button'>" +
" </div>" +
" </div>" +
@ -349,7 +377,8 @@ class Store {
"</li>\n";
this.selector.find("ul.storeItems").append(html);
let newItem = $(html);
newItem.appendTo( this.selector.find("ul.storeItems") );
this.selector.find(".emptyList").hide();
this.selector.find('#item_'+itemID+' .checkItems input').off().on('click', ev => {
if(this.checkItem(itemID)){
@ -359,7 +388,7 @@ class Store {
}
});
this.verify();
return true;
return newItem;
}
catch(e){
alert("Something failed. Try again.");
@ -389,29 +418,38 @@ class Store {
return newChecked;
}
remItem(itemID, price){
remItem(itemID){
let that = this;
if(this.state === "planning"){
// if(this.state === "planning"){
return ajaxReq({ plan: 'remItem', storeID: this.storeID, itemID: itemID, price: price })
.done(json => {
// console.log("remItem return:", json);
return that.remItemHtml(itemID);
this.remItemHtml(itemID);
// return ajaxReq({ plan: 'remItem', storeID: this.storeID, itemID: itemID, price: price })
return qAPI('plan/storeitem/delete', 'POST',{ store_id: this.storeID, plan_item_id: itemID })
.always(json => {
if(json.success){
return that.remItemHtml(itemID, true);
}
else {
setTimeout(() => {
that.selector.find('#item_'+itemID).show().addClass('error');
},10);
}
});
}
}
remItemHtml(itemID){
// for(let i = 0; i < this.items.length; i++){
// if(this.items[i].itemID === itemID){
// this.items.splice(i,1);
// break;
// }
// }
if(delete this.itemsObj[itemID]){
this.selector.find('#item_'+itemID).remove();
}
remItemHtml(itemID, remove){
remove = remove || false;
if(remove){
if(delete this.itemsObj[itemID]){
this.selector.find('#item_'+itemID).remove();
}
}
else {
this.selector.find('#item_'+itemID).fadeOut();
}
this.verify();
@ -571,7 +609,7 @@ class Store {
// console.log("remItem", $(this).hasClass("confirm"), $(this));
if($(this).hasClass("confirm")){
that.remItem($(this).attr('data-itemid'), $(this).attr("data-price"));
that.remItem($(this).closest('.list-group-item').data('itemid'), $(this).attr("data-price"));
try {
$(this).tooltip('dispose');
}
@ -760,10 +798,6 @@ function qAPI( path, method, data ){
method = method || 'GET';
data = data || {};
// if(typeof spaceID !== "undefined" && spaceID !== 0){
// data.space = spaceID;
// }
return $.ajax({
method: method,
url: "/api/v2/" + path,
@ -792,10 +826,10 @@ function handleJsonErrors(json){
}
function handleAjaxErrors(jqxhr, textStatus, error){
if(textStatus === "parsererror" && jqxhr.responseText !== ""){
alert("An error occured:\n"+jqxhr.responseText);
alert("An error occurred:\n"+jqxhr.responseText);
}
else {
alert("An error occured:\n"+error);
alert("An error occurred:\n"+error);
}
}
@ -827,4 +861,90 @@ function insertLinks(text){
}
return text;
}
}
// KEYBOARD ACCESSIBILITY
class MoveFocus {
constructor(elem) {
this.elem = elem;
// Note: Glitchy with duplicated IDs - which there currently are as of 2022-10-01.
this.tabindex = $.map( $("*[tabindex]"), (item, key) => { if( this.elem.is(item) ){ return key; } });
}
prev = () => {
if(typeof this.tabindex === "object" && this.tabindex[0] > 0){
$("*[tabindex]")[ this.tabindex[0] - 1 ].focus();
}
}
next = () => {
if(typeof this.tabindex === "object" && this.tabindex[0] >= 0){
$("*[tabindex]")[ this.tabindex[0] + 1 ].focus();
}
}
prevStore = () => {
this.elem.closest('.store').prev().find('.list-group-item:not(.emptyList)').first().focus();
}
nextStore = () => {
this.elem.closest('.store').next().find('.list-group-item:not(.emptyList)').first().focus();
}
nextForm = () => {
this.elem.parent().parent().find('.addItemForm input').first().focus();
}
prevItemRow = () => {
this.elem.prev('.list-group-item').focus();
}
nextItemRow = () => {
this.elem.next('.list-group-item').focus();
}
}
$( document ).on( 'keydown', '.store', ev => {
// if(ev.target === ev.currentTarget){
let target = $(ev.target);
if(ev.target.tagName === "INPUT"){ return; }
let move = new MoveFocus(target);
let key = "";
key += ev.ctrlKey ? "ctrl+" : '';
key += ev.shiftKey ? "shift+" : '';
key += ev.key.toLowerCase();
switch (key){
case 'arrowup':
case 'w':
move.prevItemRow()
break;
case 'arrowleft':
case 'a':
move.prev();
break;
case 'arrowdown':
case 's':
move.nextItemRow();
break;
case 'arrowright':
case 'd':
move.next();
break;
case 'shift+arrowleft':
case 'shift+a':
move.prevStore();
break;
case 'shift+arrowright':
case 'shift+d':
move.nextStore();
break;
case 'shift+s':
case 'shift+arrowdown':
move.nextForm();
break;
default:
// console.log(key, ev.key, ev);
return;
}
ev.preventDefault();
// }
});