diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..66d6e2d --- /dev/null +++ b/.htaccess @@ -0,0 +1,9 @@ + +RewriteEngine On +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +RewriteBase / +RewriteRule ^index\.php$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.php [L] + \ No newline at end of file diff --git a/README.md b/README.md index 1b5c135..437f4c4 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ The FridgeBites was developed almost entirely from scratch using PHP, without re The website is built entirely from scratch using pure PHP, without relying on any frameworks. This was done as an experimental project to enhance personal skills. Despite this, some libraries were used to add specific functionality: - [\[PHP\] PHPMailer](https://github.com/PHPMailer/PHPMailer): A library for sending emails, used for password recovery functionality. +- [\[PHP\] FPHP](https://www.fpdf.org/): library which allows to generate PDF files with pure PHP, that is to say without using the PDFlib library. - [\[JS\] Swiper.js](https://github.com/nolimits4web/swiper): A highly customizable library for creating sliders and carousels. - [\[JS\] Toastify.js](https://github.com/apvarun/toastify-js): A lightweight library for creating beautiful and customizable toast notifications. +- [\[JS\] Three.js](https://github.com/mrdoob/three.js/): project is to create an easy-to-use, lightweight, cross-browser, general-purpose 3D library. +- [\[JS\] qrcode.js](https://davidshimjs.github.io/qrcodejs/): javascript library for making QRCode. QRCode.js supports Cross-browser with HTML5 Canvas and table tag in DOM. QRCode.js has no dependencies. ## Installing diff --git a/apps/Admin/Controllers/Abstract/AdminSingleController.php b/apps/Admin/Controllers/Abstract/AdminSingleController.php index 04c08e3..ef4c618 100644 --- a/apps/Admin/Controllers/Abstract/AdminSingleController.php +++ b/apps/Admin/Controllers/Abstract/AdminSingleController.php @@ -38,6 +38,7 @@ abstract class AdminSingleController extends AdminBaseController protected $field_title = 'field_id'; protected $edit_title_template = 'Edit [:verbose] "[:field]"'; protected $can_save = true; + protected $is_new = false; /** * Function names with $object attribute. @@ -53,6 +54,7 @@ abstract class AdminSingleController extends AdminBaseController */ public function __construct($is_new = false) { + $this->is_new = $is_new; $this->context['is_new'] = $is_new; } @@ -137,7 +139,7 @@ abstract class AdminSingleController extends AdminBaseController case 'image': $file = $_FILES[$field['model_field']]; if (isset($file)) { - $path = upload_file($file, $this->model_сlass_name . '/', 'image'); + $path = upload_file($file, $this->model_сlass_name::get_table_name() . '/', 'image'); if (!empty($path)) { $field_value = $path; diff --git a/apps/Admin/Controllers/AdminCategoryController.php b/apps/Admin/Controllers/AdminCategoryController.php new file mode 100644 index 0000000..a2638cd --- /dev/null +++ b/apps/Admin/Controllers/AdminCategoryController.php @@ -0,0 +1,21 @@ + 'name', + 'input_type' => 'text', + 'input_attrs' => ['required'] + ] + ); +} \ No newline at end of file diff --git a/apps/Admin/Controllers/AdminCategoryListController.php b/apps/Admin/Controllers/AdminCategoryListController.php new file mode 100644 index 0000000..aa824f2 --- /dev/null +++ b/apps/Admin/Controllers/AdminCategoryListController.php @@ -0,0 +1,17 @@ + 'field_name' + ); + protected $single_router_name = 'admin:category'; + protected $create_router_name = 'admin:category-new'; + protected $verbose_name = "category"; + protected $verbose_name_multiply = "categories"; +} \ No newline at end of file diff --git a/apps/Admin/Controllers/AdminDeleteController.php b/apps/Admin/Controllers/AdminDeleteController.php index ed9e085..4458d61 100644 --- a/apps/Admin/Controllers/AdminDeleteController.php +++ b/apps/Admin/Controllers/AdminDeleteController.php @@ -9,6 +9,55 @@ use Lycoreco\Apps\Users\Models\UserModel; */ class AdminDeleteController extends Abstract\AdminBaseController { + const DELETE_MODELS = [ + [ + 'url_name' => 'users', + 'model' => 'Lycoreco\Apps\Users\Models\UserModel', + 'field_display' => 'field_username', + 'back_to' => 'admin:user', + 'success_to' => 'admin:user-list' + ], + [ + 'url_name' => 'user-banlist', + 'model' => 'Lycoreco\Apps\Users\Models\BanlistModel', + 'field_display' => 'field_reason', + 'back_to' => 'admin:ban' + ], + [ + 'url_name' => 'recipes', + 'model' => 'Lycoreco\Apps\Recipes\Models\RecipeModel', + 'field_display' => 'field_title', + 'back_to' => 'admin:recipe', + 'success_to' => 'admin:recipe-list' + ], + [ + 'url_name' => 'ingredients', + 'model' => 'Lycoreco\Apps\Recipes\Models\IngredientModel', + 'field_display' => 'field_name', + 'back_to' => 'admin:ingredient', + 'success_to' => 'admin:ingredient-list' + ], + [ + 'url_name' => 'categories', + 'model' => 'Lycoreco\Apps\Recipes\Models\CategoryModel', + 'field_display' => 'field_name', + 'back_to' => 'admin:category', + 'success_to' => 'admin:category-list' + ], + [ + 'url_name' => 'recipe-ingredients', + 'model' => 'Lycoreco\Apps\Recipes\Models\IngredientInRecipeModel', + 'field_display' => 'ingredient_name', + 'back_to' => 'admin:ing-cat-rel', + ], + [ + 'url_name' => 'recipe-reviews', + 'model' => 'Lycoreco\Apps\Recipes\Models\ReviewsModel', + 'field_display' => 'field_title', + 'back_to' => 'admin:review', + ], + ]; + protected $template_name = APPS_PATH . '/Admin/Templates/delete.php'; /** @@ -22,17 +71,15 @@ class AdminDeleteController extends Abstract\AdminBaseController $id = $this->url_context['url_2']; $model_class = ''; - switch ($this->url_context['url_1']) { - case 'users': - $model_class = "Lycoreco\Apps\Users\Models\UserModel"; + foreach (self::DELETE_MODELS as $delete_model) { + if($this->url_context['url_1'] == $delete_model['url_name']) { + $model_class = $delete_model['model']; break; - case 'user-banlist': - $model_class = "Lycoreco\Apps\Users\Models\BanlistModel"; - break; - - default: - return null; + } } + if(empty($model_class)) + return null; + return $model_class::get(array( [ 'name' => 'obj.id', @@ -52,16 +99,12 @@ class AdminDeleteController extends Abstract\AdminBaseController // Display field to show what's model $field = ''; $back_url = ''; - switch ($this->url_context['url_1']) { - case 'users': - $back_url = get_permalink('admin:user', [$model->get_id()]); - $field = 'field_username'; - break; - case 'user-banlist': - $back_url = get_permalink('admin:ban', [$model->get_id()]); - $field = 'field_reason'; - break; + foreach (self::DELETE_MODELS as $delete_model) { + if($this->url_context['url_1'] == $delete_model['url_name']) { + $back_url = get_permalink($delete_model['back_to'], [$model->get_id()]); + $field = $delete_model['field_display']; + } } $context['back_url'] = $back_url; @@ -80,14 +123,14 @@ class AdminDeleteController extends Abstract\AdminBaseController $model->delete(); // Redirect after delete - $type_model = $this->url_context['url_1']; - switch ($type_model) { - case 'users': - $link = get_permalink('admin:user-list'); - break; - default: - $link = get_permalink('admin:home'); + $link = get_permalink('admin:home'); + foreach (self::DELETE_MODELS as $delete_model) { + if($this->url_context['url_1'] == $delete_model['url_name']) { + if(isset($delete_model['success_to'])) { + $link = get_permalink($delete_model['success_to']); + } break; + } } redirect_to($link); } diff --git a/apps/Admin/Controllers/AdminHomeController.php b/apps/Admin/Controllers/AdminHomeController.php index 6dbf9e6..ac399a2 100644 --- a/apps/Admin/Controllers/AdminHomeController.php +++ b/apps/Admin/Controllers/AdminHomeController.php @@ -1,6 +1,12 @@ modify("-1 month"); + $datetime_month_ago_text = $datetime_month_ago->format('Y-m-d H:i:s' . '\''); + + $context['user_count'] = UserModel::count(array( + [ + 'name' => 'obj.register_at', + 'type' => '>=', + 'value' => $datetime_month_ago_text + ] + )); + $context['recipes_count'] = RecipeModel::count(array( + [ + 'name' => 'obj.created_at', + 'type' => '>=', + 'value' => $datetime_month_ago_text + ] + )); + $context['ban_count'] = BanlistModel::count(array( + [ + 'name' => 'obj.created_at', + 'type' => '>=', + 'value' => $datetime_month_ago_text + ] + )); + $context['reviews_count'] = ReviewsModel::count(array( + [ + 'name' => 'obj.created_at', + 'type' => '>=', + 'value' => $datetime_month_ago_text + ] + )); + $context['latest_recipes'] = RecipeModel::filter( + array(), + ['-obj.created_at'] + ); return $context; diff --git a/apps/Admin/Controllers/AdminRecipeController.php b/apps/Admin/Controllers/AdminRecipeController.php new file mode 100644 index 0000000..b9c88c6 --- /dev/null +++ b/apps/Admin/Controllers/AdminRecipeController.php @@ -0,0 +1,78 @@ + 'title', + 'input_type' => 'text', + 'input_attrs' => ['required'] + ], + [ + 'model_field' => 'instruction', + 'input_type' => 'textarea', + 'input_attrs' => ['required'] + ], + [ + 'model_field' => 'image_url', + 'input_type' => 'image', + 'input_label' => 'Image', + ], + [ + 'model_field' => 'estimated_time', + 'input_type' => 'number', + 'input_attrs' => ['required'], + 'input_label' => 'Estimated time (min)' + ], + [ + 'model_field' => 'estimated_price', + 'input_type' => 'number', + 'input_attrs' => ['required'], + 'input_label' => 'Estimated price ($)' + ], + [ + 'model_field' => 'status', + 'input_type' => 'select', + 'input_attrs' => ['required'], + 'input_values' => RecipeModel::STATUS + ], + [ + 'model_field' => 'created_at', + 'input_type' => 'text', + 'dynamic_save' => false, + 'input_label' => 'Created at', + 'input_attrs' => ['disabled'] + ] + ); + protected function before_save(&$object) + { + if($this->is_new) + { + $object->field_author_id = CURRENT_USER->get_id(); + } + } + + public function __construct($is_new = false) { + parent::__construct($is_new); + $this->fields[] = [ + 'model_field' => 'category_id', + 'input_type' => 'select', + 'input_label' => 'Categories', + 'input_attrs' => ['required'], + 'input_values' => CategoryModel::get_cat_values() + ]; + } +} \ No newline at end of file diff --git a/apps/Admin/Controllers/AdminRecipeListController.php b/apps/Admin/Controllers/AdminRecipeListController.php new file mode 100644 index 0000000..cdcd88e --- /dev/null +++ b/apps/Admin/Controllers/AdminRecipeListController.php @@ -0,0 +1,20 @@ + 'field_title', + 'Price' => 'get_price()', + 'Status ' => 'get_status()', + 'Creaated at' => 'field_created_at', + ); + protected $single_router_name = 'admin:recipe'; + protected $create_router_name = 'admin:recipe-new'; + protected $verbose_name = "recipe"; + protected $verbose_name_multiply = "recipes"; +} \ No newline at end of file diff --git a/apps/Admin/Controllers/AdminReviewControler.php b/apps/Admin/Controllers/AdminReviewControler.php new file mode 100644 index 0000000..debce04 --- /dev/null +++ b/apps/Admin/Controllers/AdminReviewControler.php @@ -0,0 +1,44 @@ + 'title', + 'input_type' => 'text', + 'input_attrs' => ['required'] + ], + [ + 'model_field' => 'rating', + 'input_type' => 'number', + 'input_attrs' => ['required'] + ], + [ + 'model_field' => 'content', + 'input_type' => 'textarea', + 'input_attrs' => ['required'] + ], + [ + 'model_field' => 'status', + 'input_type' => 'select', + 'input_attrs' => ['required'], + 'input_values' => ReviewsModel::STATUS + ], + [ + 'model_field' => 'created_at', + 'input_type' => 'text', + 'dynamic_save' => false, + 'input_label' => 'Created at', + 'input_attrs' => ['disabled'] + ] + ); +} \ No newline at end of file diff --git a/apps/Admin/Controllers/AdminReviewListController.php b/apps/Admin/Controllers/AdminReviewListController.php new file mode 100644 index 0000000..c531ed5 --- /dev/null +++ b/apps/Admin/Controllers/AdminReviewListController.php @@ -0,0 +1,17 @@ + 'field_title', + 'Rating' => 'field_rating', + 'Status ' => 'get_status()', + 'Creaated at' => 'field_created_at', + ); + protected $single_router_name = 'admin:review'; + protected $verbose_name = "review"; + protected $verbose_name_multiply = "reviews"; +} \ No newline at end of file diff --git a/apps/Admin/Controllers/IngredientController.php b/apps/Admin/Controllers/IngredientController.php new file mode 100644 index 0000000..07681eb --- /dev/null +++ b/apps/Admin/Controllers/IngredientController.php @@ -0,0 +1,27 @@ + 'name', + 'input_type' => 'text', + 'input_attrs' => ['required'] + ], + [ + 'model_field' => 'unit_name', + 'input_type' => 'text', + 'input_attrs' => ['required'], + 'input_label' => 'Unit name' + ] + ); +} \ No newline at end of file diff --git a/apps/Admin/Controllers/IngredientListController.php b/apps/Admin/Controllers/IngredientListController.php new file mode 100644 index 0000000..f4795d7 --- /dev/null +++ b/apps/Admin/Controllers/IngredientListController.php @@ -0,0 +1,18 @@ + 'field_name', + 'Unit' => 'field_unit_name' + ); + protected $single_router_name = 'admin:ingredient'; + protected $create_router_name = 'admin:ingredient-new'; + protected $verbose_name = "ingredient"; + protected $verbose_name_multiply = "ingredients"; +} \ No newline at end of file diff --git a/apps/Admin/Controllers/IngredientRecipeRelController.php b/apps/Admin/Controllers/IngredientRecipeRelController.php new file mode 100644 index 0000000..4b7afe7 --- /dev/null +++ b/apps/Admin/Controllers/IngredientRecipeRelController.php @@ -0,0 +1,50 @@ + 'recipe_id', + 'input_type' => 'number', + 'dynamic_save' => false, + 'input_label' => 'Recipe id', + 'input_attrs' => ['required', 'disabled'] + ], + [ + 'model_field' => 'amount', + 'input_type' => 'text', + 'input_attrs' => ['required'] + ], + ); + protected function get_model() + { + $model = parent::get_model(); + + if($this->context['is_new']) + $model->field_recipe_id = (int)$this->url_context['url_1']; + + return $model; + } + public function __construct($is_new = false) { + parent::__construct($is_new); + + $this->fields[] = [ + 'model_field' => 'ingredient_id', + 'input_type' => 'select', + 'input_label' => 'Ingedient', + 'input_attrs' => ['required'], + 'input_values' => IngredientModel::get_ing_values() + ]; + } +} \ No newline at end of file diff --git a/apps/Admin/Controllers/IngredientRecipeRelListController.php b/apps/Admin/Controllers/IngredientRecipeRelListController.php new file mode 100644 index 0000000..1f319e3 --- /dev/null +++ b/apps/Admin/Controllers/IngredientRecipeRelListController.php @@ -0,0 +1,28 @@ + 'ingredient_name', + 'Amount' => 'get_count()' + ); + protected $single_router_name = 'admin:ing-cat-rel'; + protected $verbose_name = "ingredients in recipe"; + protected $verbose_name_multiply = "ingredients in recipe"; + + public function custom_filter_fields() + { + $recipe_id = $this->url_context['url_1']; + return array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe_id + ] + ); + } +} diff --git a/apps/Admin/Templates/components/admin-header.php b/apps/Admin/Templates/components/admin-header.php index d5b508a..8ac60d3 100644 --- a/apps/Admin/Templates/components/admin-header.php +++ b/apps/Admin/Templates/components/admin-header.php @@ -1,23 +1,60 @@ 'Dashboard', + 'icon' => 'fa-solid fa-house', + 'router_name' => 'admin:home', + ], + [ + 'name' => 'Users', + 'icon' => 'fa-solid fa-users', + 'router_name' => 'admin:user-list', + ], + [ + 'name' => 'Recipes', + 'icon' => 'fa-solid fa-bowl-food', + 'router_name' => 'admin:recipe-list' + ], + [ + 'name' => 'Categories', + 'icon' => 'fa-solid fa-tag', + 'router_name' => 'admin:category-list' + ], + [ + 'name' => 'Ingredients', + 'icon' => 'fa-solid fa-carrot', + 'router_name' => 'admin:ingredient-list' + ], + [ + 'name' => 'Reviews', + 'icon' => 'fa-solid fa-comment', + 'router_name' => 'admin:review-list' + ] +]; ?> + <?php echo get_title_website($title); ?> - + + - + + - - + + @@ -26,9 +63,10 @@ use Lycoreco\Includes\Routing\Router; +
-
-
- - - - \ No newline at end of file +
\ No newline at end of file diff --git a/apps/Admin/Templates/home.php b/apps/Admin/Templates/home.php index b5a4c82..80bbd62 100644 --- a/apps/Admin/Templates/home.php +++ b/apps/Admin/Templates/home.php @@ -2,36 +2,36 @@
-

Stats per month

+

Stats per month

- +
-
Total sales
-
0$
+
New recipes
+
- +
-
Profit
-
0$
+
New reviews
+
- +
-
Orders
-
0
+
Total bans
+
@@ -41,49 +41,47 @@
New users
-
0
+
-

Quick tools

+

Quick tools

-

Latest orders

+

Latest resipes

- - - - - + + + + - + - - - - - + + + + diff --git a/apps/Admin/Templates/list-view.php b/apps/Admin/Templates/list-view.php index bb7e9e6..d797f56 100644 --- a/apps/Admin/Templates/list-view.php +++ b/apps/Admin/Templates/list-view.php @@ -1,21 +1,21 @@
-

+

- + New
-
+
- +
diff --git a/apps/Admin/Templates/single-view.php b/apps/Admin/Templates/single-view.php index 9dd2349..6810879 100644 --- a/apps/Admin/Templates/single-view.php +++ b/apps/Admin/Templates/single-view.php @@ -5,7 +5,7 @@ $disabled_attr = $context['can_save'] ? '' : 'disabled'; ?>
-

+

is_saved()): ?> @@ -112,12 +112,12 @@ $disabled_attr = $context['can_save'] ? '' : 'disabled';
is_saved()): ?> - Delete + Delete - +

diff --git a/apps/Admin/Templates/widgets/ingredients_in_recipe.php b/apps/Admin/Templates/widgets/ingredients_in_recipe.php new file mode 100644 index 0000000..8a6c078 --- /dev/null +++ b/apps/Admin/Templates/widgets/ingredients_in_recipe.php @@ -0,0 +1,22 @@ +
+
Ingredients
+
+
+ + +
+ + +
get_count() ?>
+
+ + +
No ingredients
+ +
+ +
+
\ No newline at end of file diff --git a/apps/Admin/Templates/widgets/ingredients_in_recipe_relation.php b/apps/Admin/Templates/widgets/ingredients_in_recipe_relation.php new file mode 100644 index 0000000..edda2a6 --- /dev/null +++ b/apps/Admin/Templates/widgets/ingredients_in_recipe_relation.php @@ -0,0 +1,10 @@ +
+
Model relations
+
+
Ingredient:
+ + +
Recipe:
+ +
+
\ No newline at end of file diff --git a/apps/Admin/Templates/widgets/recipe_author.php b/apps/Admin/Templates/widgets/recipe_author.php new file mode 100644 index 0000000..df6280f --- /dev/null +++ b/apps/Admin/Templates/widgets/recipe_author.php @@ -0,0 +1,6 @@ +
+
Author
+ +
\ No newline at end of file diff --git a/apps/Admin/Templates/widgets/user_banlist.php b/apps/Admin/Templates/widgets/user_banlist.php index 09484d9..5b1bb81 100644 --- a/apps/Admin/Templates/widgets/user_banlist.php +++ b/apps/Admin/Templates/widgets/user_banlist.php @@ -19,8 +19,8 @@
\ No newline at end of file diff --git a/apps/Admin/components.php b/apps/Admin/components.php index eb1c6e8..c001c84 100644 --- a/apps/Admin/components.php +++ b/apps/Admin/components.php @@ -4,6 +4,11 @@ use Lycoreco\Apps\Users\Models\{ UserModel, BanlistModel }; +use Lycoreco\Apps\Recipes\Models\{ + RecipeModel, + IngredientModel, + IngredientInRecipeModel +}; function the_admin_header(string $title) { @@ -30,4 +35,49 @@ function the_user_banlist(UserModel $user) ['-obj.end_at']); require APPS_PATH . '/Admin/Templates/widgets/user_banlist.php'; +} + +function the_recipe_author(RecipeModel $recipe) +{ + $author = UserModel::get(array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $recipe->field_author_id + ] + )); + + require APPS_PATH . '/Admin/Templates/widgets/recipe_author.php'; +} + +function the_recipe_ingredients(RecipeModel $recipe) +{ + $ingredients = IngredientInRecipeModel::filter(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe->get_id() + ] + )); + + require APPS_PATH . '/Admin/Templates/widgets/ingredients_in_recipe.php'; +} +function the_recipe_ingredients_relation(IngredientInRecipeModel $relation) +{ + $ingredient = IngredientModel::get(array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $relation->field_ingredient_id + ] + )); + $recipe = RecipeModel::get(array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $relation->field_recipe_id + ] + )); + + require APPS_PATH . '/Admin/Templates/widgets/ingredients_in_recipe_relation.php'; } \ No newline at end of file diff --git a/apps/Admin/urls.php b/apps/Admin/urls.php index e40d49d..ddc6efa 100644 --- a/apps/Admin/urls.php +++ b/apps/Admin/urls.php @@ -9,6 +9,10 @@ $admin_urls = [ // Lists new Path('/admin/users', new Controllers\AdminUserListController(), 'user-list'), + new Path('/admin/recipes',new Controllers\AdminRecipeListController(), 'recipe-list'), + new Path('/admin/categories',new Controllers\AdminCategoryListController(), 'category-list'), + new Path('/admin/ingredients',new Controllers\IngredientListController(), 'ingredient-list'), + new Path('/admin/reviews', new Controllers\AdminReviewListController(), 'review-list'), ////// Single object /////// // User @@ -20,6 +24,26 @@ $admin_urls = [ new Path('/admin/user/[:int]/ban/new', new Controllers\AdminBanController(true), 'ban-new'), new Path('/admin/ban/[:int]', new Controllers\AdminBanController(false), 'ban'), + // Recipe + new Path('/admin/recipe/[:int]', new Controllers\AdminRecipeController(), 'recipe'), + new Path('/admin/recipe/new', new Controllers\AdminRecipeController(true), 'recipe-new'), + + // Category + new Path('/admin/category/[:int]', new Controllers\AdminCategoryController(), 'category'), + new Path('/admin/category/new', new Controllers\AdminCategoryController(true), 'category-new'), + + // Ingredient + new Path('/admin/ingredient/[:int]', new Controllers\IngredientController(), 'ingredient'), + new Path('/admin/ingredient/new', new Controllers\IngredientController(true), 'ingredient-new'), + + // Reviews + new Path('/admin/review/[:int]', new Controllers\AdminReviewControler(), 'review'), + + // Recipe ingedient relation + new Path('/admin/recipe/[:int]/ingredients', new Controllers\IngredientRecipeRelListController(), 'ing-cat-rel-list'), + new Path('/admin/recipe/ingredient/[:int]', new Controllers\IngredientRecipeRelController(), 'ing-cat-rel'), + new Path('/admin/recipe/[:int]/ingredient/new', new Controllers\IngredientRecipeRelController(true), 'ing-cat-rel-new'), + // Dynamic delete for every object type new Path('/admin/[:string]/[:int]/delete', new Controllers\AdminDeleteController(), 'delete') ]; diff --git a/apps/Ajax/Controllers/AjaxController.php b/apps/Ajax/Controllers/AjaxController.php index d13258a..60ab3b2 100644 --- a/apps/Ajax/Controllers/AjaxController.php +++ b/apps/Ajax/Controllers/AjaxController.php @@ -3,6 +3,7 @@ namespace Lycoreco\Apps\Ajax\Controllers; use Lycoreco\Includes\BaseController; +use Lycoreco\Includes\Model\ValidationError; class AjaxController extends BaseController { @@ -15,10 +16,7 @@ class AjaxController extends BaseController require_once APPS_PATH . '/Ajax/ajax-actions.php'; $context['result'] = ""; - $json = file_get_contents('php://input'); - $data = json_decode($json, true); - - $action = $data['action'] ?? false; + $action = $_POST['action'] ?? false; // If request from other site if (!in_array($_SERVER['HTTP_HOST'], ALLOWED_HOSTS)) { @@ -34,9 +32,16 @@ class AjaxController extends BaseController $action = "ajax_" . $action; try { - $context['result'] = $action($data['args']); - } catch (\Exception $ex) { + $context['result'] = $action(); + } + catch (ValidationError $ex) { + $context['result'] = get_ajax_error($ex->getMessage(), 400); + return $context; + } + catch (\Exception $ex) { + http_response_code(500); $context['result'] = get_ajax_error($ex->getMessage()); + return $context; } diff --git a/apps/Ajax/Templates/ajax-result.php b/apps/Ajax/Templates/ajax-result.php index fd5f3ae..4b3fdca 100644 --- a/apps/Ajax/Templates/ajax-result.php +++ b/apps/Ajax/Templates/ajax-result.php @@ -1,2 +1,2 @@ 2, - 'name' => 'Genshin Impact' - ], - [ - 'id' => 3, - 'name' => 'Zenless zone zero' - ], - [ - 'id' => 4, - 'name' => 'Honkai Star Rail' - ], - [ - 'id' => 5, - 'name' => 'Honkai Impact' - ], - ]; - $result['results'] = []; - - foreach ($data as $key => $value) { - if(str_contains($value['name'], $search_query)) - $result['results'][] = $value; +function ajax_search() +{ + $search_query = $_POST['query'] ?? null; + if (!isset($search_query)) { + return get_ajax_error("Missing 'query' parameter.", 400); } - sleep(3); + if (!CURRENT_USER) { + return get_ajax_error('You are not authorized', 401); + } + $result = array(); - return json_encode($result, JSON_PRETTY_PRINT); + $recipes = array(); + $recipe_models = RecipeModel::filter( + count: 5, + search: $search_query + ); + foreach ($recipe_models as $recipe_model) { + $recipe = $recipe_model->getAssocArr(); + $recipe['image_url'] = $recipe_model->get_image_url(); + $recipe['url'] = $recipe_model->get_absolute_url(); + $recipes[] = $recipe; + } + + $result['count'] = count($recipes); + $result['result'] = $recipes; + + return $result; +} + +function ajax_usermenu() +{ + $recipe_id = $_POST['recipe_id'] ?? null; + $dayofweek = $_POST['dayofweek'] ?? null; + + if (!CURRENT_USER) { + return get_ajax_error('You are not authorized', 401); + } + if (!isset($recipe_id)) { + return get_ajax_error("Missing 'recipe_id' parameter.", 400); + } + if (!isset($dayofweek)) { + return get_ajax_error("Missing 'dayofweek' parameter.", 400); + } + $result = array(); + + $user_menu = RecipeUserMenu::get(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe_id + ], + [ + 'name' => 'obj.user_id', + 'type' => '=', + 'value' => CURRENT_USER->get_id() + ] + )); + // If user choose optiopn 'remove' + if($dayofweek == 'remove') { + if($user_menu) { + $user_menu->delete(); + $result['success'] = 'This recipe was removed from your list'; + return $result; + } + else { + return get_ajax_error("This recipe in your menu is not exists", 400); + } + } + + // If not exists, add new recipe in user menu + if(!$user_menu) { + $user_menu = new RecipeUserMenu(); + $user_menu->field_recipe_id = $recipe_id; + $user_menu->field_user_id = CURRENT_USER->get_id(); + } + + $user_menu->field_dayofweek = $dayofweek; + $user_menu->save(); + + $result['success'] = 'You have successfully added the recipe to your menu.'; + return $result; +} +function ajax_create_ingredient() +{ + $ingredient_name = $_POST['name'] ?? null; + $ingredient_unit = $_POST['unit'] ?? null; + + if (!CURRENT_USER) { + return get_ajax_error('You are not authorized', 401); + } + if (!isset($ingredient_name)) { + return get_ajax_error("Missing 'name' parameter.", 400); + } + if (!isset($ingredient_unit)) { + return get_ajax_error("Missing 'unit' parameter.", 400); + } + + $ingredient = new IngredientModel(); + $ingredient->field_name = $ingredient_name; + $ingredient->field_unit_name = $ingredient_unit; + $ingredient->save(); + + $result = array(); + $result['success'] = 'You have successfully added new ingredient'; + return $result; +} +function ajax_search_ingredient() +{ + $search_query = $_POST['query'] ?? null; + if (!isset($search_query)) { + return get_ajax_error("Missing 'query' parameter.", 400); + } + $result = array(); + + $ingredients = IngredientModel::filter( + count: 5, + search: $search_query + ); + $result['count'] = count($ingredients); + $result['result'] = array_map(function($ing) { + return $ing->getAssocArr(); + }, $ingredients); + + return $result; +} + +function ajax_favorites() +{ + $recipe_id = $_POST['recipe_id'] ?? null; + $type = $_POST['type'] ?? 'add'; // add, remove + + if (!CURRENT_USER) { + return get_ajax_error('You are not authorized', 401); + } + if (!isset($recipe_id)) { + return get_ajax_error("Missing 'recipe_id' parameter.", 400); + } + if($type != 'add' && $type != 'remove') + { + return get_ajax_error("'type' parameter can be only 'add' or 'remove'", 400); + } + $result = array(); + + $favorite = FavoriteModel::get(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe_id + ], + [ + 'name' => 'obj.user_id', + 'type' => '=', + 'value' => CURRENT_USER->get_id() + ] + )); + + if($type == 'remove') { + if($favorite) { + $favorite->delete(); + $result['success'] = 'This recipe was removed from your favorite list'; + return $result; + } + else { + return get_ajax_error("This recipe from your favorites list does not exist.", 400); + } + } + + if(!$favorite) + $favorite = new FavoriteModel(); + + $favorite->field_user_id = CURRENT_USER->get_id(); + $favorite->field_recipe_id = $recipe_id; + $favorite->save(); + + $result['success'] = 'You have successfully added the recipe to your favorites list'; + return $result; } \ No newline at end of file diff --git a/apps/Index/Controllers/HomepageController.php b/apps/Index/Controllers/HomepageController.php index 2114f69..fbe7d17 100644 --- a/apps/Index/Controllers/HomepageController.php +++ b/apps/Index/Controllers/HomepageController.php @@ -2,9 +2,51 @@ namespace Lycoreco\Apps\Index\Controllers; +use Lycoreco\Apps\Recipes\Models\CategoryModel; +use Lycoreco\Apps\Recipes\Models\RecipeModel; +use Lycoreco\Apps\Recipes\Models\RecipeUserMenu; +use Lycoreco\Apps\Recipes\Models\ReviewsModel; use Lycoreco\Includes\BaseController; +require_once(INCLUDES_PATH . '/Const/recipes.php'); + class HomepageController extends BaseController { protected $template_name = APPS_PATH . '/Index/Templates/index.php'; + + public function get_context_data() { + $context = parent::get_context_data(); + + $context['latest_recipes'] = RecipeModel::filter(array( + [ + 'name' => 'obj.status', + 'type' => '=', + 'value' => 'publish' + ]), + ['-obj.created_at'], + 3 + ); + $context['categories'] = CategoryModel::filter(); + $dayNumber = date("w"); + $dayofweek = DAYS_OF_WEEK[$dayNumber]; + + $context['reviews'] = ReviewsModel::filter(array( + [ + 'name' => 'obj.status', + 'type' => '=', + 'value' => 'publish' + ]), + ['-obj.created_at'], + 6 + ); + + if(CURRENT_USER) { + $context['usermenu_recipe_prefetch'] = RecipeUserMenu::get_prefetch_recipes(CURRENT_USER, $dayofweek); + } + else { + $context['usermenu_recipe_prefetch'] = [ ]; + } + + return $context; + } } diff --git a/apps/Index/Templates/error.php b/apps/Index/Templates/error.php index 7b31a6e..e04c8cc 100644 --- a/apps/Index/Templates/error.php +++ b/apps/Index/Templates/error.php @@ -1,23 +1,28 @@ -get_http_error(), - '', - 'error', + $error->get_http_error(), + '', + 'error', [ ['robots', 'nofollow, noindex'] - ]); + ] +); + +/** + * @var PageError + */ - /** - * @var PageError - */ - ?> -
-
get_http_error() ?>
-
getMessage() ?>
+
+
+
get_http_error() ?>
+
getMessage() ?>
+
- \ No newline at end of file diff --git a/apps/Index/Templates/index.php b/apps/Index/Templates/index.php index e84bb09..b58968c 100644 --- a/apps/Index/Templates/index.php +++ b/apps/Index/Templates/index.php @@ -1,4 +1,6 @@ - +
+
+
+
+
+

Your Fridge.

+

Your Rules.

+

Our Recipes.

+
+

Discover delicious recipes tailored to exactly what you have on hand — no extra shopping trips, no wasted food, just tasty meals made easy.

+
+ +
+
+
-
-

Your Fridge. Your Rules. - Our Recipes. -

-

Latest Recipes Added

@@ -22,42 +34,21 @@ @@ -76,87 +67,14 @@
@@ -168,240 +86,58 @@

Recent User Reviews

- + +
- reviewed recipe + reviewed recipe
-

Just Like Mom's

+

recipe_title ?>

-

I made this spaghetti last night and it was absolutely delicious! The sauce was rich and - flavorful—just the right balance of garlic, herbs, and tomato. I added a pinch of chili - flakes for a little kick, and it turned out perfect...

+

get_excerpt() ?>

-

GreenDavid004

-

2023-10-01

-
-
- -
-
- reviewed recipe -
-

Just Like Mom's

-
-
-

I made this spaghetti last night and it was absolutely delicious! The sauce was rich and - flavorful—just the right balance of garlic, herbs, and tomato. I added a pinch of chili - flakes for a little kick, and it turned out perfect...

-
-
-

GreenDavid004

-

2023-10-01

-
-
-
-
- reviewed recipe -
-

Just Like Mom's

-
-
-

I made this spaghetti last night and it was absolutely delicious! The sauce was rich and - flavorful—just the right balance of garlic, herbs, and tomato. I added a pinch of chili - flakes for a little kick, and it turned out perfect...

-
-
-

GreenDavid004

-

2023-10-01

-
-
-
-
- reviewed recipe -
-

Just Like Mom's

-
-
-

I made this spaghetti last night and it was absolutely delicious! The sauce was rich and - flavorful—just the right balance of garlic, herbs, and tomato. I added a pinch of chili - flakes for a little kick, and it turned out perfect...

-
-
-

GreenDavid004

-

2023-10-01

-
-
-
-
- reviewed recipe -
-

Just Like Mom's

-
-
-

I made this spaghetti last night and it was absolutely delicious! The sauce was rich and - flavorful—just the right balance of garlic, herbs, and tomato. I added a pinch of chili - flakes for a little kick, and it turned out perfect...

-
-
-

GreenDavid004

-

2023-10-01

-
-
-
-
- reviewed recipe -
-

Just Like Mom's

-
-
-

I made this spaghetti last night and it was absolutely delicious! The sauce was rich and - flavorful—just the right balance of garlic, herbs, and tomato. I added a pinch of chili - flakes for a little kick, and it turned out perfect...

-
-
-

GreenDavid004

-

2023-10-01

+

author_username ?>

+

get_date() ?>

+
+ + + +
+

No meals added for

+

You have not added any recipes for , please go to the catalog and add recipes.

+
+ +
+ + + + 'obj.category_id', + 'type' => '=', + 'value' => $category_id + ); + } + $ingredient_ids = isset($_GET['ingredient']) ? $_GET['ingredient'] : null; + if ($ingredient_ids) { + $fields[] = array( + 'name' => 'tb2.ingredient_id', + 'type' => 'IN', + 'value' => $ingredient_ids, + 'is_having' => true + ); + } + $fields[] = array( + 'name' => 'obj.status', + 'type' => '=', + 'value' => 'publish' + ); + + $context['recipes_count'] = RecipeModel::count($fields); + $context['recipes'] = RecipeModel::filter( + $fields, + ['-obj.created_at'], + CATALOG_MAX_RECIPES, + offset: calc_page_offset(CATALOG_MAX_RECIPES, $context['page']) + ); + + return $context; + } +} diff --git a/apps/Recipes/Controllers/DailyMealsController.php b/apps/Recipes/Controllers/DailyMealsController.php new file mode 100644 index 0000000..dad7262 --- /dev/null +++ b/apps/Recipes/Controllers/DailyMealsController.php @@ -0,0 +1,27 @@ +__model)) + return $this->__model; + + $this->__model = RecipeModel::get( + array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $this->url_context['url_1'] + ] + ) + ); + if (empty($this->__model)) + throw new HttpExceptions\NotFound404('Recipe not found'); + + return $this->__model; + } + + public function get_context_data() + { + $context = parent::get_context_data(); + $recipe = $this->get_model(); + + $fpdf = new RecipePDF($recipe); + $fpdf->PrintRecipe(); + + $context['fpdf'] = $fpdf; + return $context; + } +} diff --git a/apps/Recipes/Controllers/FavoritesController.php b/apps/Recipes/Controllers/FavoritesController.php new file mode 100644 index 0000000..c5047d6 --- /dev/null +++ b/apps/Recipes/Controllers/FavoritesController.php @@ -0,0 +1,50 @@ + 'obj.user_id', + 'type' => '=', + 'value' => CURRENT_USER->get_id() + ] + )); + $fav_ids = array_map(function($recipe) { + return $recipe->field_recipe_id; + }, $favorite_recipes); + + if(!empty($fav_ids)) { + $context['recipes'] = RecipeModel::filter( + array( + [ + 'name' => 'obj.id', + 'type' => 'IN', + 'value' => $fav_ids + ] + ), + ['-obj.created_at'], + FAVORITES_MAX_RECIPES + ); + } + else { + $context['recipes'] = []; + } + + return $context; + } +} \ No newline at end of file diff --git a/apps/Recipes/Controllers/SingleRecipeController.php b/apps/Recipes/Controllers/SingleRecipeController.php new file mode 100644 index 0000000..0ba4ebe --- /dev/null +++ b/apps/Recipes/Controllers/SingleRecipeController.php @@ -0,0 +1,133 @@ +context['reviews_error'] = 'You are not authorized'; + return; + } + $rating = $_POST['rating-select'] ?? null; + $title = $_POST['review-title'] ?? null; + $content = $_POST['review-body-input'] ?? null; + + $recipe = $this->get_model(); + + $review = new ReviewsModel(); + $review->field_title = $title; + $review->field_content = $content; + $review->field_rating = $rating; + $review->field_status = 'pending'; + $review->field_user_id = CURRENT_USER->get_id(); + $review->field_recipe_id = $recipe->get_id(); + + try { + $review->save(); + $this->context['display_review_form'] = false; + } + catch(ValidationError $ex) { + $this->context['reviews_error'] = $ex->getMessage(); + } + catch(Exception $ex) { + $this->context['reviews_error'] = 'Unexpected error'; + } + } + + protected function get_model() + { + if (isset($this->__model)) + return $this->__model; + + $this->__model = RecipeModel::get( + array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $this->url_context['url_1'] + ] + ) + ); + if (empty($this->__model)) + throw new HttpExceptions\NotFound404('Recipe not found'); + + return $this->__model; + } + + public function get_context_data() + { + $context = parent::get_context_data(); + $recipe = $this->get_model(); + + + $context['recipe'] = $recipe; + $context['display_review_form'] = true; + + $context['author'] = UserModel::get(array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $recipe->field_author_id + ] + )); + + $context['ingredients'] = IngredientInRecipeModel::filter(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe->get_id() + ] + )); + + $context['reviews'] = ReviewsModel::filter(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe->get_id() + ], + [ + 'name' => 'obj.status', + 'type' => '=', + 'value' => 'publish' + ], + )); + $context['reviews_average'] = '0.0'; + if(!empty($context['reviews'])) { + $sum = array_sum(array_column($context['reviews'], 'field_rating')); + $count = count($context['reviews']); + $context['reviews_average'] = number_format($sum / $count, 1); + } + + if(CURRENT_USER) { + $review_exists = ReviewsModel::count(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $recipe->get_id() + ], + [ + 'name' => 'obj.user_id', + 'type' => '=', + 'value' => CURRENT_USER->get_id() + ] + )); + if($review_exists > 0) + $context['display_review_form'] = false; + } + + return $context; + } +} diff --git a/apps/Recipes/Controllers/SingleSubmitController.php b/apps/Recipes/Controllers/SingleSubmitController.php new file mode 100644 index 0000000..39df223 --- /dev/null +++ b/apps/Recipes/Controllers/SingleSubmitController.php @@ -0,0 +1,80 @@ +field_title = $title; + $recipe->field_instruction = $description; + + if($image) { + $file_url = upload_file($image, RecipeModel::get_table_name() . '/', 'image'); + $recipe->field_image_url = $file_url; + } + $recipe->field_estimated_time = $estimated_time; + $recipe->field_estimated_price = $estimated_price; + $recipe->field_category_id = $category_id; + $recipe->field_author_id = CURRENT_USER->get_id(); + $recipe->field_status = 'pending'; + + try { + $recipe->save(); + } + catch (ValidationError $ex) { + $this->context['error_message'] = $ex->getMessage(); + } + catch (Exception $ex) { + $this->context['error_message'] = "Unexpected error"; + } + + for ($i = 0; $i < count($ingredient_ids); $i++) { + $ing_id = $ingredient_ids[$i]; + $ing_count = (int) $ingredient_counts[$i]; + + if($ing_count <= 0) + continue; + + $relation = new IngredientInRecipeModel(); + $relation->field_ingredient_id = $ing_id; + $relation->field_amount = $ing_count; + $relation->field_recipe_id = $recipe->get_id(); + + $relation->save(); + } + + redirect_to($recipe->get_absolute_url()); + } + + public function get_context_data() + { + $context = parent::get_context_data(); + + $context['category_options'] = CategoryModel::get_cat_values(); + + + return $context; + } +} \ No newline at end of file diff --git a/apps/Recipes/Models/CategoryModel.php b/apps/Recipes/Models/CategoryModel.php new file mode 100644 index 0000000..93b0663 --- /dev/null +++ b/apps/Recipes/Models/CategoryModel.php @@ -0,0 +1,35 @@ + 'int', + 'name' => 'string', + ]; + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + name VARCHAR(255) NOT NULL + );'); + return $result; + } + + public static function get_cat_values() + { + $cat_list = self::filter(array(), count: 200); + $result = array(); + foreach($cat_list as $cat) { + $result[] = [ $cat->get_id(), $cat->field_name ]; + } + return $result; + } +} \ No newline at end of file diff --git a/apps/Recipes/Models/FavoriteModel.php b/apps/Recipes/Models/FavoriteModel.php new file mode 100644 index 0000000..83c3071 --- /dev/null +++ b/apps/Recipes/Models/FavoriteModel.php @@ -0,0 +1,47 @@ + 'int', + 'recipe_id' => 'int', + 'user_id' => 'int', + ]; + + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + recipe_id INT NOT NULL, + user_id INT NOT NULL, + + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );'); + return $result; + } + + public function valid() + { + $recipe = RecipeModel::get(array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $this->field_recipe_id + ] + )); + if(!$recipe) + return ['Recipe does not exist']; + + return true; + } +} \ No newline at end of file diff --git a/apps/Recipes/Models/IngredientInRecipeModel.php b/apps/Recipes/Models/IngredientInRecipeModel.php new file mode 100644 index 0000000..7ecc9ea --- /dev/null +++ b/apps/Recipes/Models/IngredientInRecipeModel.php @@ -0,0 +1,55 @@ + [ + 'tb1.name AS ingredient_name', + 'tb1.unit_name AS ingredient_unit' + ], + 'join_table' => 'ingredients tb1 ON tb1.id = obj.ingredient_id' + ] + ); + + static protected $table_name = 'recipe_ingredients'; + static protected $table_fields = [ + 'id' => 'int', + 'ingredient_id' => 'int', + 'recipe_id' => 'int', + 'amount' => 'int' + ]; + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + ingredient_id INT NOT NULL, + recipe_id INT NOT NULL, + amount INT NOT NULL, + + FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE, + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE + );'); + return $result; + } + public function get_count() + { + return $this->field_amount . ' ' . $this->ingredient_unit; + } + + public function __toString() + { + return $this->ingredient_name; + } +} \ No newline at end of file diff --git a/apps/Recipes/Models/IngredientModel.php b/apps/Recipes/Models/IngredientModel.php new file mode 100644 index 0000000..424eb0f --- /dev/null +++ b/apps/Recipes/Models/IngredientModel.php @@ -0,0 +1,38 @@ + 'int', + 'name' => 'string', + 'unit_name' => 'string' + ]; + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + name VARCHAR(255) NOT NULL, + unit_name VARCHAR(20) NULL + );'); + return $result; + } + public static function get_ing_values() + { + $ing_list = self::filter(array(), count: 200); + $result = array(); + foreach($ing_list as $ing) { + $result[] = [ $ing->get_id(), $ing->field_name . ' ('. $ing->field_unit_name .')' ]; + } + return $result; + } +} \ No newline at end of file diff --git a/apps/Recipes/Models/RecipeModel.php b/apps/Recipes/Models/RecipeModel.php new file mode 100644 index 0000000..0f56be8 --- /dev/null +++ b/apps/Recipes/Models/RecipeModel.php @@ -0,0 +1,158 @@ + [ + 'tb1.name AS category_name' + ], + 'join_table' => 'categories tb1 ON tb1.id = obj.category_id' + ], + [ + 'field' => [ + 'us.username AS author_username' + ], + 'join_table' => 'users us ON us.id = obj.author_id' + ], + [ + 'field' => [], + 'join_table' => 'recipe_ingredients tb2 ON tb2.recipe_id = obj.id' + ] + ); + static protected $table_fields = [ + 'id' => 'int', + 'title' => 'string', + 'instruction' => 'string', + 'image_url' => 'string', + 'estimated_time' => 'int', + 'estimated_price' => 'float', + 'category_id' => 'int', + 'author_id' => 'int', + 'status' => 'string', + 'created_at' => 'DateTime' + ]; + protected static function get_additional_fields() + { + $add_fields = parent::get_additional_fields(); + + // If user is authorized, we also check product in thw wishlist + if (CURRENT_USER) { + $add_fields = array_merge($add_fields, array( + [ + "field" => [ + "MAX(CASE WHEN fav.user_id = " . CURRENT_USER->get_id() . " THEN 1 ELSE 0 END) AS is_in_favorite" + ], + "join_table" => "recipe_favorites fav ON fav.recipe_id = obj.id" + ] + )); + } + if (CURRENT_USER) { + $add_fields = array_merge($add_fields, array( + [ + "field" => [ + "MAX(m.dayofweek) AS in_usermenu" + ], + "join_table" => "recipe_usermenu m ON m.recipe_id = obj.id AND m.user_id = " . CURRENT_USER->get_id() + ] + )); + } + + return $add_fields; + } + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + title VARCHAR(255) NOT NULL, + instruction TEXT NOT NULL, + image_url VARCHAR(255) NULL, + estimated_time INT NOT NULL, + estimated_price INT NOT NULL, + category_id INT NOT NULL, + author_id INT NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN (\'publish\', \'pending\')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE + );'); + return $result; + } + public function get_absolute_url() + { + return get_permalink('recipes:single', [$this->get_id()]); + } + public function get_price() + { + return $this->field_estimated_price . '$'; + } + public function get_status() + { + return ucfirst($this->field_status); + } + public function get_image_url() + { + if ($this->field_image_url) + return MEDIA_URL . $this->field_image_url; + + return null; + } + + public function get_html_instruction(): string + { + return nl2br(trim($this->field_instruction)); + } + + public function get_time() + { + return $this->field_estimated_time . " minutes"; + } + + public function valid() + { + $errors = []; + + if(mb_strlen($this->field_title) <= 3) + $errors[] = 'Title field must be more than 3 symbols'; + + if($this->field_estimated_time <= 0) + $errors[] = "Estimated time must be more than 0 minutes"; + + if($this->field_estimated_price <= 0) + $errors[] = "Estimated price must be more than 0$"; + + if($this->field_category_id === null) + $errors[] = "Recipe must have category"; + + if(!empty($errors)) + return $errors; + + return true; + } +} diff --git a/apps/Recipes/Models/RecipeUserMenu.php b/apps/Recipes/Models/RecipeUserMenu.php new file mode 100644 index 0000000..4141b05 --- /dev/null +++ b/apps/Recipes/Models/RecipeUserMenu.php @@ -0,0 +1,93 @@ + 'int', + 'dayofweek' => 'string', + 'recipe_id' => 'int', + 'user_id' => 'int', + 'created_at' => 'DateTime' + ]; + + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + dayofweek VARCHAR(150) NOT NULL, + recipe_id INT NOT NULL, + user_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );'); + return $result; + } + public function valid() + { + require_once INCLUDES_PATH . '/Const/recipes.php'; + + if (!in_array($this->field_dayofweek, DAYS_OF_WEEK)) + return ['Day of Week is not valid']; + + $recipe = RecipeModel::get(array( + [ + 'name' => 'obj.id', + 'type' => '=', + 'value' => $this->field_recipe_id + ] + )); + if (!$recipe) + return ['Recipe does not exist']; + + return true; + } + + public static function get_prefetch_recipes(UserModel $user, string $week) { + $usermenus = RecipeUserMenu::filter(array( + [ + 'name' => 'obj.user_id', + 'type' => '=', + 'value' => $user->get_id(), + ], + [ + 'name' => 'obj.dayofweek', + 'type' => '=', + 'value' => $week + ] + ), count: 100); + + if(empty($usermenus)) + return []; + + $usermenu_ids = array_map(function($usermenu) { + return $usermenu->field_recipe_id; + }, $usermenus); + + + $recipes = RecipeModel::filter(array( + [ + 'name' => 'obj.id', + 'type' => 'IN', + 'value' => $usermenu_ids + ] + )); + + $prefetch_recipes_ings = prefetch_related($recipes, 'Recipes:IngredientInRecipeModel', 'recipe_id'); + + return $prefetch_recipes_ings; + } +} diff --git a/apps/Recipes/Models/ReviewsModel.php b/apps/Recipes/Models/ReviewsModel.php new file mode 100644 index 0000000..2ef4c69 --- /dev/null +++ b/apps/Recipes/Models/ReviewsModel.php @@ -0,0 +1,121 @@ + 'int', + 'title' => 'string', + 'content' => 'string', + 'rating' => 'int', + 'status' => 'string', + + 'recipe_id' => 'int', + 'user_id' => 'int', + 'created_at' => 'DateTime', + ]; + static protected $additional_fields = array( + [ + 'field' => [ + 'us.username AS author_username' + ], + 'join_table' => 'users us ON us.id = obj.user_id' + ], + [ + 'field' => [ + 're.title AS recipe_title', + 're.image_url AS recipe_image' + ], + 'join_table' => 'recipes re ON re.id = obj.recipe_id' + ] + ); + + public static function init_table() + { + $result = db_query('CREATE TABLE ' . static::$table_name . ' ( + id INT AUTO_INCREMENT PRIMARY KEY, + + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + rating INT NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN (\'publish\', \'pending\')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + recipe_id INT NOT NULL, + user_id INT NOT NULL, + + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );'); + return $result; + } + + public function get_html_content(): string + { + return nl2br(trim($this->field_content)); + } + public function get_status() + { + return ucfirst($this->field_status); + } + public function get_date() + { + $date = new DateTime($this->field_created_at); + return $date->format('d.m.Y'); + } + public function get_excerpt() + { + $max_length = 100; + $excerpt = mb_substr($this->field_content, 0, $max_length); + + if(mb_strlen($this->field_content) > $max_length) + $excerpt .= '...'; + + return $excerpt; + } + public function get_recipe_image() + { + $recipe = new RecipeModel(); + $recipe->field_image_url = $this->recipe_image; + return $recipe->get_image_url(); + } + public function valid() { + $errors = array(); + + if(empty($this->field_title)) + $errors[] = 'Title field is empty'; + + if(empty($this->field_content)) + $errors[] = 'Content field is empty'; + + $this->field_rating = (int)$this->field_rating; + if($this->field_rating < 1 || $this->field_rating > 5) { + $errors[] = 'Rating must be between 1 and 5'; + } + + if (empty($errors)) + return true; + + return $errors; + } +} \ No newline at end of file diff --git a/apps/Recipes/Templates/catalog.php b/apps/Recipes/Templates/catalog.php new file mode 100644 index 0000000..6419310 --- /dev/null +++ b/apps/Recipes/Templates/catalog.php @@ -0,0 +1,97 @@ + +
+
+
+
+ +
+ +
+
+
+
+
+
+

+ Categories

+ + +
+
    + get_id(); + + $is_checked = false; + $category = $_GET['category'] ?? null; + if ($category == $cat->get_id()) + $is_checked = true; + ?> +
  • + > + +
  • + +
+
+
+
+

+ Ingredients

+ +
+
    + get_id(); + + $is_checked = false; + $ingredients = $_GET['ingredient'] ?? null; + if ($ingredients) { + if (in_array($ing->get_id(), $ingredients)) + $is_checked = true; + } + ?> +
  • + > + +
  • + +
+
+
+ + + Reset + + +
+
+
+
+
+ \ No newline at end of file diff --git a/apps/Recipes/Templates/components/catalog-item.php b/apps/Recipes/Templates/components/catalog-item.php new file mode 100644 index 0000000..659be4b --- /dev/null +++ b/apps/Recipes/Templates/components/catalog-item.php @@ -0,0 +1,15 @@ + +
+ <?= $recipe->field_title ?> +
+
+
+

field_title ?>

+
+
+
+ category_name ?> +
+
+
+
\ No newline at end of file diff --git a/apps/Recipes/Templates/components/recipe-ings-item.php b/apps/Recipes/Templates/components/recipe-ings-item.php new file mode 100644 index 0000000..1dfcb7a --- /dev/null +++ b/apps/Recipes/Templates/components/recipe-ings-item.php @@ -0,0 +1,18 @@ + +
+ meal-img +
+
+
+

field_title ?>

+ field_estimated_time ?> mins to make +
+
+
    + +
  • ingredient_name ?>
  • + +
+
+
+
\ No newline at end of file diff --git a/apps/Recipes/Templates/daily-meals.php b/apps/Recipes/Templates/daily-meals.php new file mode 100644 index 0000000..fa18f80 --- /dev/null +++ b/apps/Recipes/Templates/daily-meals.php @@ -0,0 +1,43 @@ + +
+
+ $recipe_prefetches) { +?> + +
+

Your Menu for

+
+ + +
+
+ +
+

No meals added for

+

You have not added any recipes for , please go to the catalog and add recipes.

+
+ + +
+
+ \ No newline at end of file diff --git a/apps/Recipes/Templates/export-pdf.php b/apps/Recipes/Templates/export-pdf.php new file mode 100644 index 0000000..42d52f9 --- /dev/null +++ b/apps/Recipes/Templates/export-pdf.php @@ -0,0 +1 @@ +Output('I', 'recipe.pdf') ?> \ No newline at end of file diff --git a/apps/Recipes/Templates/favorites.php b/apps/Recipes/Templates/favorites.php new file mode 100644 index 0000000..d35800a --- /dev/null +++ b/apps/Recipes/Templates/favorites.php @@ -0,0 +1,36 @@ + + +
+
+ +
+ +
+ +
+ Nothing to show +
+ +
+
+ + \ No newline at end of file diff --git a/apps/Recipes/Templates/single-submit.php b/apps/Recipes/Templates/single-submit.php new file mode 100644 index 0000000..429ea6b --- /dev/null +++ b/apps/Recipes/Templates/single-submit.php @@ -0,0 +1,122 @@ + + + +
+
+

Submit a Recipe

+
+ + +
+ * +
+ +
+ * +
+ +
+ + * + +
Order numberMethodTotal priceBuyerCreated atTitlePriceStatusCreated At
field_order_number ?>field_method ?>get_total_price() ?>get_buyer_username() ?>field_created_at ?>field_title ?>get_price() ?>get_status() ?>field_created_at ?>
+ + + + + + + + + + + + + + + +
NameCount
+
+ + + +
+
+ +
+ + * +
+ +
+ + * +
+ +
+ + * +
+ +
+ * +
+ + + + +
+ + + + + + +
+ + + \ No newline at end of file diff --git a/apps/Recipes/Templates/single.php b/apps/Recipes/Templates/single.php new file mode 100644 index 0000000..148bd79 --- /dev/null +++ b/apps/Recipes/Templates/single.php @@ -0,0 +1,194 @@ +field_title, + 'This is a single recipe page where you can view the details of the recipe, including ingredients, instructions, and more.', + 'recipe', + [ + + ['keywords', 'recipes, cooking, food, cuisine'], + ] +); +?> + + + + +
+
+
+
+ <?php echo $context['recipe']->field_title; ?> +
+
+
+

field_title; ?>

+
+
+
+ Category: + category_name ?> +
+
+ Author: + field_username ?> +
+
+ Estimated Price: + get_price(); ?> +
+
+ Time To Make: + get_time(); ?> +
+
+ Ingredients: + +
+
+ Date Created: + field_created_at; ?> +
+
+
+ +
+
+ + +
+ +
+
+ + + + + +
+ + +
+
+
+
+
+

Instructions

+ + get_html_instruction(); ?> +
+
+

Ingredients

+ + + + + + + + + + + + + + + +
NameCount
get_count(); ?>
+
+
+
+

Reviews

+

Average Rating:

+
+
+

All Reviews

+ +
Nothing to show
+ + + +
+ field_rating ?>   field_title ?> +
+ get_html_content() ?> +
+
+
+ author_username ?> +
+
get_date() ?>
+
+
+ +
+ +
+

Your Review

+ +
+
+ + +
+
+ +
+ + + +
+
+ +
+
+
+
+ + + + + \ No newline at end of file diff --git a/apps/Recipes/Utils/RecipePDF.php b/apps/Recipes/Utils/RecipePDF.php new file mode 100644 index 0000000..7943851 --- /dev/null +++ b/apps/Recipes/Utils/RecipePDF.php @@ -0,0 +1,111 @@ +recipe = $recipe; + } + function Header() + { + $recipe = $this->recipe; + + // Background + $this->SetTextColor(0, 139, 112); + + $this->SetFont('Arial', 'B', 16); + $this->Image(BASE_PATH . '/assets/images/fridgeLogo.png', 10, 6, 20); + $this->Cell(80); + $this->Cell(30, 10, $recipe->field_title, 0, 0, 'C'); + $this->Ln(8); + + $this->SetFont('Arial', '', 12); + $this->SetTextColor(0, 0, 0); + $this->Cell(80); + $this->Cell(30, 10, $recipe->category_name, 0, 0, 'C'); + $this->Ln(20); + } + function Footer() + { + // Position at 1.5 cm from bottom + $this->SetY(-15); + // Arial italic 8 + $this->SetFont('Arial', 'I', 8); + // Text color in gray + $this->SetTextColor(128); + // Page number + $this->Cell(0, 3, 'Page ' . $this->PageNo(), 0, 0, 'C'); + $this->Ln(); + $this->Cell(0, 10, 'LycoReco', 0, 0, 'C'); + } + + function RecipeTable() + { + $header = array('Ingredient', 'Count'); + + $ingredients = IngredientInRecipeModel::filter(array( + [ + 'name' => 'obj.recipe_id', + 'type' => '=', + 'value' => $this->recipe->get_id() + ] + )); + + // Colors, line width and bold font + $this->SetFillColor(0, 139, 122); + $this->SetTextColor(255); + $this->SetDrawColor(179, 179, 179); + $this->SetLineWidth(.3); + $this->SetFont('', 'B'); + // Header + $w = array(80, 35); + $this->Cell(35); + for ($i = 0; $i < count($header); $i++) + $this->Cell($w[$i], 7, $header[$i], 1, 0, 'C', true); + $this->Ln(); + + // Body + $this->SetFillColor(224,235,255); + $this->SetTextColor(0); + $this->SetFont(''); + foreach ($ingredients as $ing) { + $this->Cell(35); + $this->Cell($w[0], 6, $ing->ingredient_name, 'LR', 0, 'L', false); + $this->Cell($w[1], 6, $ing->get_count(), 'LR', 0, 'L', false); + $this->Ln(); + } + $this->Cell(35); + $this->Cell(array_sum($w),0,'','T'); + $this->Ln(10); + } + + function RecipeContent() + { + $recipe = $this->recipe; + + $this->SetFont('Times', '', 14); + $this->SetTextColor(0); + $this->MultiCell(0, 5, txt: $recipe->field_instruction); + $this->Ln(); + $this->SetFont('', 'I'); + $this->Cell(0, 5, '(Bon appetit!)'); + } + + + public function PrintRecipe() + { + $this->SetTitle($this->recipe->field_title); + $this->AddPage(); + $this->RecipeTable(); + $this->RecipeContent(); + } +} diff --git a/apps/Recipes/components.php b/apps/Recipes/components.php new file mode 100644 index 0000000..a56faab --- /dev/null +++ b/apps/Recipes/components.php @@ -0,0 +1,12 @@ + { }, - onSuccess = (data) => { }, +function sendAjax(action, + args = [], + onLoad = () => { }, + onSuccess = (data) => { }, onError = (error) => { }) { - + onLoad(); - + fetch('/ajax', { method: 'POST', headers: { @@ -25,17 +25,17 @@ function sendAjax(action, 'args': args }) }) - .then(response => response.json().then(data => ({ data, response }))) - .then(({ data, response }) => { - if (!response.ok) { - throw new Error(data.error || `HTTP error! Status: ${response.status}`); - } - - onSuccess(data); - }) - .catch(error => { - onError(error); - }); + .then(response => response.json().then(data => ({ data, response }))) + .then(({ data, response }) => { + if (!response.ok) { + throw new Error(data.error || `HTTP error! Status: ${response.status}`); + } + + onSuccess(data); + }) + .catch(error => { + onError(error); + }); } function showToastify(message, type = "info") { Toastify({ @@ -43,6 +43,87 @@ function showToastify(message, type = "info") { gravity: "bottom", position: "left", className: type, - duration: 3000 }) - .showToast(); + duration: 3000 + }) + .showToast(); } + +document.addEventListener('DOMContentLoaded', function () { + const toggle = document.getElementById('menu-toggle'); + const nav = document.querySelector('.nav'); + const icon = document.getElementById('menu-icon'); + + toggle.addEventListener('click', function () { + nav.classList.toggle('nav-open'); + if (nav.classList.contains('nav-open')) { + icon.classList.remove('fa-bars'); + icon.classList.add('fa-xmark'); + } else { + icon.classList.remove('fa-xmark'); + icon.classList.add('fa-bars'); + } + }); +}); + +const searchInput = document.getElementById('search-input'); +const searchResults = document.getElementById('search-results'); + +let searchTimeout; + +searchInput.addEventListener('input', function () { + clearTimeout(searchTimeout); + + searchTimeout = setTimeout(async () => { + let searchValue = this.value.trim(); + + + if (searchValue.length < 3) { + searchResults.innerHTML = ''; + searchResults.hidden = true; + return; + } + + const formData = new FormData(); + formData.append('action', 'search'); + formData.append('query', searchValue); + + const response = await fetch('/ajax', { + method: 'POST', + body: formData + }); + + const json = await response.json(); + + if (!response.ok) { + const message = json.error || 'Something went wrong.'; + showToastify(message, 'error'); + return; + } + + const results = json.result; + + searchResults.innerHTML = ''; + + if (results.length > 0) { + results.forEach(result => { + searchResults.innerHTML += ` +
+ + ${result.field_title} + +
+ `; + }); + searchResults.hidden = false; + } else { + searchResults.innerHTML = `
No recipes found
`; + searchResults.hidden = false; + } + }, 300); +}); + +document.addEventListener('click', function (event) { + if (!searchResults.contains(event.target) && event.target !== searchInput) { + searchResults.hidden = true; + } +}); \ No newline at end of file diff --git a/assets/js/single-submit.js b/assets/js/single-submit.js new file mode 100644 index 0000000..843dc46 --- /dev/null +++ b/assets/js/single-submit.js @@ -0,0 +1,146 @@ +const addIngredientBtn = document.getElementById('add-ingredient-btn'); +const overlay = document.getElementById('overlay'); +const ingModal = document.getElementById('ingredient-modal'); + +addIngredientBtn.addEventListener('click', () => { + ingModal.classList.remove('hidden'); + overlay.classList.remove('hidden'); +}); + + +document.addEventListener('click', (e) => { + if (!ingModal.contains(e.target) && !addIngredientBtn.contains(e.target)) { + ingModal.classList.add('hidden'); + overlay.classList.add('hidden'); + } +}); + +const ingredientSubmitBtn = document.getElementById('new-ingredient-submit'); +const ingredientName = document.getElementById('ing-name-input'); +const ingredientUnit = document.getElementById('ing-unit-input'); + +ingredientSubmitBtn.addEventListener('click', async (e) => { + + const name = ingredientName.value.trim(); + const unit = ingredientUnit.value.trim(); + + if (!name || !unit) { + showToastify('Please fill in all fields.', 'error'); + return; + } + + const formData = new FormData(); + formData.append('action', 'create_ingredient'); + formData.append('name', name); + formData.append('unit', unit); + + const response = await fetch('/ajax', { + method: 'POST', + body: formData + }); + + const json = await response.json(); + + if (!response.ok) { + const message = json.error; + showToastify(message, 'error'); + return; + } + + showToastify(json.success, 'success'); + ingredientName.value = ''; + ingredientUnit.value = ''; + ingModal.classList.add('hidden'); + overlay.classList.add('hidden'); +}); + + +const searchIngredientWrapper = document.getElementById('search-ingredient'); +const searchIngInput = searchIngredientWrapper.querySelector('input'); +const searchIngDropdown = searchIngredientWrapper.querySelector('.custom-select-dropdown'); + +const tableIngRows = document.querySelector('.ing-table-rows'); +const ingredientsAdded = new Map(); + +searchIngInput.addEventListener('input', function () { + clearTimeout(searchTimeout); + + searchTimeout = setTimeout(async () => { + let searchValue = this.value.trim(); + + + if (searchValue.length < 3) { + searchIngInput.innerHTML = ''; + searchIngDropdown.hidden = true; + return; + } + + const formData = new FormData(); + formData.append('action', 'search_ingredient'); + formData.append('query', searchValue); + + const response = await fetch('/ajax', { + method: 'POST', + body: formData + }); + + const json = await response.json(); + + if (!response.ok) { + const message = json.error || 'Something went wrong.'; + showToastify(message, 'error'); + return; + } + + const results = json.result; + + searchIngDropdown.innerHTML = ''; + + if (results.length > 0) { + results.forEach(ingredient => { + const ingredientName = `${ingredient.field_name} (${ingredient.field_unit_name})`; + + const option = document.createElement('div'); + option.className = "dropdown-item hover-anim"; + option.textContent = ingredientName; + + option.addEventListener('click', (e) => { + const row = document.createElement('tr'); + + if(ingredientsAdded.get(ingredient.id) != undefined) { + showToastify("This field already exists.", "error"); + return; + } + + row.innerHTML += ` + ${ingredientName} + + +
+ + + `; + row.querySelector(".ing-id").value = ingredient.id; + + + const rowDeleteBtn = row.querySelector('i'); + rowDeleteBtn.addEventListener('click', (e) => { + ingredientsAdded.delete(ingredient.id); + row.remove(); + }); + + ingredientsAdded.set(ingredient.id, ingredientName); + tableIngRows.append(row); + searchIngInput.value = ''; + searchIngDropdown.hidden = true; + }); + + searchIngDropdown.append(option); + }); + searchIngDropdown.hidden = false; + } else { + searchIngDropdown.innerHTML = `
No recipes found
`; + searchIngDropdown.hidden = false; + } + }, 300); +}); \ No newline at end of file diff --git a/assets/js/single.js b/assets/js/single.js new file mode 100644 index 0000000..c8754d7 --- /dev/null +++ b/assets/js/single.js @@ -0,0 +1,140 @@ +const recipeId = document.getElementById('recipe-id').textContent; +const toggleBtn = document.getElementById('day-select'); +const dropdown = document.getElementById('custom-select-dropdown'); + +toggleBtn.addEventListener('click', () => { + dropdown.classList.toggle('hidden'); +}); + +document.addEventListener('click', (e) => { + if (!dropdown.contains(e.target) && !toggleBtn.contains(e.target)) { + dropdown.classList.add('hidden'); + } +}); + + + +const options = document.querySelectorAll('.dropdown-item'); +const inUsermenu = document.getElementById('in-usermenu').textContent; + +if (inUsermenu) { + options.forEach(option => { + if (option.getAttribute('data-value') === inUsermenu && option.getAttribute('data-value') !== 'remove') { + option.classList.add('dropdown-selected'); + toggleBtn.textContent = option.textContent; + } + }); +} +options.forEach(option => { + option.addEventListener('click', async (e) => { + const selectedValue = option.getAttribute('data-value'); + + options.forEach(opt => opt.classList.remove('dropdown-selected')); + + const formData = new FormData(); + formData.append('action', 'usermenu'); + formData.append('recipe_id', recipeId); + formData.append('dayofweek', selectedValue); + + const response = await fetch('/ajax', { + method: 'POST', + body: formData + }); + + const json = await response.json(); + + if (!response.ok) { + const message = json.error; + showToastify(message, 'error'); + return; + } + + + if (selectedValue === 'remove') { + toggleBtn.textContent = 'Add to list'; + } else { + toggleBtn.textContent = option.textContent; + option.classList.add('dropdown-selected'); + } + + dropdown.classList.add('hidden'); + + showToastify(json.success, 'success'); + }); +}); + +const qrContainer = document.getElementById("qrcode"); +const downloadLink = document.getElementById("downloadLink"); +const qrBtn = document.getElementById("qr-btn"); +const qrPopup = document.querySelector(".qr-popup"); +const overlay = document.getElementById('overlay'); + +qrBtn.addEventListener("click", () => { + + qrPopup.classList.toggle("hidden") + overlay.classList.toggle("hidden") + + qrContainer.innerHTML = ""; + + + new QRCode(qrContainer, { + text: window.location.href, + width: 200, + height: 200 + }); + + + setTimeout(() => { + const qrImg = qrContainer.querySelector("img"); + if (qrImg) { + downloadLink.href = qrImg.src; + } + }, 500); +}); + +document.addEventListener('click', (e) => { + if (!qrPopup.contains(e.target) && !qrBtn.contains(e.target)) { + qrPopup.classList.add("hidden"); + overlay.classList.add("hidden"); + } +}); + +const favoriteBtn = document.getElementById('favorite-btn'); +const favoriteIcon = favoriteBtn.querySelector('i'); + + +favoriteBtn.addEventListener('click', async (e) => { + const isFavorite = favoriteBtn.classList.contains('active'); + const type = isFavorite ? 'remove' : 'add'; + + const formData = new FormData(); + formData.append('action', 'favorites'); + formData.append('recipe_id', recipeId); + formData.append('type', type); + + favoriteBtn.disabled = true; + const response = await fetch('/ajax', { + method: 'POST', + body: formData + }); + favoriteBtn.disabled = false; + + const json = await response.json(); + + if (!response.ok) { + const message = json.error; + showToastify(message, 'error'); + return; + } + favoriteBtn.classList.toggle('active'); + + if (type == 'add') { + favoriteIcon.classList.remove('fa-regular'); + favoriteIcon.classList.add('fa-solid'); + } else { + favoriteIcon.classList.add('fa-regular'); + favoriteIcon.classList.remove('fa-solid'); + } + + showToastify(json.success, 'success'); +}); \ No newline at end of file diff --git a/assets/js/threejs-scene.js b/assets/js/threejs-scene.js new file mode 100644 index 0000000..ec7e5b9 --- /dev/null +++ b/assets/js/threejs-scene.js @@ -0,0 +1,68 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +// Сцена, камера, рендерер +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0xeaf8eb); + +const width = 430; +const height = 350; + +const camera = new THREE.PerspectiveCamera( + 75,width / height, 0.1, 1000 +); +camera.position.set(9, 3, 3); // Слегка сверху и сбоку +camera.lookAt(0, 0, 0); + +const renderer = new THREE.WebGLRenderer({ antialias: true, canvas: document.querySelector('#food-3d'), }); +renderer.setSize(width, height, false); + +// Еда +const loader = new GLTFLoader(); +let foodModel; +loader.load('/assets/food_3d.glb', function(gltf) { + foodModel = gltf.scene; + foodModel.position.set(0, -1, 1.5); + foodModel.scale.set(25, 25, 30); + scene.add(foodModel); + +}, function (xhr) { + // called while loading is progressing + console.log((xhr.loaded / xhr.total * 100) + '% loaded'); + }, + function (error) { + // called when loading has errors + console.log('An error happened', error); + }); + +// Свет (не обязателен для MeshNormalMaterial, но пригодится для других) +const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); +scene.add(ambientLight); + +const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4); +directionalLight.position.set(3, 5, 2); +directionalLight.castShadow = false; +scene.add(directionalLight); + +const hemisphereLight = new THREE.HemisphereLight(0xaaaaaa, 0x444444, 0.6); +scene.add(hemisphereLight); + +let mouseX = 0; +let mouseY = 0; + +window.addEventListener('mousemove', (event) => { + // Нормализуем координаты мыши в [-1, 1] + mouseX = (event.clientX / window.innerWidth) * 2 - 1; + mouseY = -((event.clientY / window.innerHeight) * 2 - 1); +}); + +function animate() { + requestAnimationFrame(animate); + + foodModel.position.z = 1.5 + mouseX * 0.7; + foodModel.position.y = -1 + mouseY * -0.7; + + renderer.render(scene, camera); +} + +animate(); \ No newline at end of file diff --git a/assets/qrcode/qrcode.min.js b/assets/qrcode/qrcode.min.js new file mode 100644 index 0000000..993e88f --- /dev/null +++ b/assets/qrcode/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/components/templates/layout/footer.php b/components/templates/layout/footer.php index 1004d29..494f5cc 100644 --- a/components/templates/layout/footer.php +++ b/components/templates/layout/footer.php @@ -20,7 +20,7 @@ - + diff --git a/components/templates/layout/header.php b/components/templates/layout/header.php index b7717b9..4395f58 100644 --- a/components/templates/layout/header.php +++ b/components/templates/layout/header.php @@ -1,5 +1,6 @@ + @@ -11,14 +12,16 @@ - - + + - + @@ -36,28 +39,46 @@ +
-
\ No newline at end of file +
\ No newline at end of file diff --git a/composer.json b/composer.json index b6b3fe3..db4b3e6 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ } }, "require": { - "phpmailer/phpmailer": "^6.10" + "phpmailer/phpmailer": "^6.10", + "setasign/fpdf": "^1.8" } } diff --git a/composer.lock b/composer.lock index 462c06e..84bf483 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a475eab76e37559e0cc7396ccf30c49e", + "content-hash": "a6db4a4cd2450cf3fbd46a573661b495", "packages": [ { "name": "phpmailer/phpmailer", @@ -86,6 +86,52 @@ } ], "time": "2025-04-24T15:19:31+00:00" + }, + { + "name": "setasign/fpdf", + "version": "1.8.6", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDF.git", + "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", + "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "ext-zlib": "*" + }, + "type": "library", + "autoload": { + "classmap": [ + "fpdf.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Olivier Plathey", + "email": "oliver@fpdf.org", + "homepage": "http://fpdf.org/" + } + ], + "description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.", + "homepage": "http://www.fpdf.org", + "keywords": [ + "fpdf", + "pdf" + ], + "support": { + "source": "https://github.com/Setasign/FPDF/tree/1.8.6" + }, + "time": "2023-06-26T14:44:25+00:00" } ], "packages-dev": [], diff --git a/fridgebitesDocs2.pdf b/fridgebitesDocs2.pdf new file mode 100644 index 0000000..71e935d Binary files /dev/null and b/fridgebitesDocs2.pdf differ diff --git a/fridgebitesDocs3 b/fridgebitesDocs3 new file mode 100644 index 0000000..13f5f8c Binary files /dev/null and b/fridgebitesDocs3 differ diff --git a/functions.php b/functions.php index 21b8b83..0159aa7 100644 --- a/functions.php +++ b/functions.php @@ -133,13 +133,13 @@ function the_pagination(int $count, int $elem_per_page, int $current_page) continue; $GET['page'] = $i; ?> -
  • +
  • 0 && $current_page <= $total_pages): ?>
  • -
    +
  • @@ -147,7 +147,7 @@ function the_pagination(int $count, int $elem_per_page, int $current_page) -
  • +
  • @@ -252,4 +252,54 @@ function send_email(string $subject, string $body, string $altBody, string $to_a $mail->send(); } -?> \ No newline at end of file + +/** + * Collects related objects for each origin object based on a given foreign key. + * + * Returns an array in the following format: + * [ + * [ + * 'origin' => $object, // the origin object + * 'relations' => $objects // related objects from the target model + * ], + * ... + * ] + * + * @param array $objects Array of origin objects (their IDs will be used for matching) + * @param string $model_name_format Target model name in "app:model" format to fetch related objects from + * @param string $field_key Foreign key in the related model that refers to the origin object ID + * @return array An array of relation mappings between origin and related objects + */ +function prefetch_related(array $objects, string $model_name_format, string $field_key) +{ + [$app, $model] = explode(':', $model_name_format); + $model_name = "Lycoreco\Apps\\" . $app . "\Models\\" . $model; + + if (!class_exists($model_name)) { + throw new InvalidArgumentException("Model class $model_name does not exist."); + } + + $keys = $keys = array_map(fn($obj) => $obj->get_id(), $objects); + + if (empty($keys)) + return []; + + $related_objects = $model_name::filter(array( + [ + 'name' => 'obj.' . $field_key, + 'type' => 'IN', + 'value' => $keys + ] + )); + + return array_map(function ($object) use ($related_objects, $field_key) { + $rels = array_filter($related_objects, function ($rel) use ($object, $field_key) { + return $rel->{'field_' . $field_key} == $object->get_id(); + }); + + return [ + 'origin' => $object, + 'relations' => array_values($rels) + ]; + }, $objects); +} diff --git a/includes/Const/recipes.php b/includes/Const/recipes.php new file mode 100644 index 0000000..19b83d5 --- /dev/null +++ b/includes/Const/recipes.php @@ -0,0 +1,5 @@ + 'monday', 2 => 'tuesday', 3 => 'wednesday', 4 => 'thursday', 5 => 'friday', 6 => 'saturday', 0 => 'sunday' +)); \ No newline at end of file diff --git a/includes/Model/BaseModel.php b/includes/Model/BaseModel.php index d924618..238842f 100644 --- a/includes/Model/BaseModel.php +++ b/includes/Model/BaseModel.php @@ -344,7 +344,7 @@ abstract class BaseModel else return false; } - static function count($fields, $search, $additional_fields = array()) + static function count($fields, $search = '', $additional_fields = array()) { $filter_result = static::filter( $fields, @@ -364,6 +364,9 @@ abstract class BaseModel else return $filter_result[0]->func_total_count; } + public function getAssocArr() { + return get_object_vars($this); + } public function delete() { @@ -436,7 +439,7 @@ abstract class BaseModel /** * Return model from Mysql result * @param array $pdo_result pdo resut FETCH_MODE = FETCH_ASSOC - * @return array + * @return array(self) */ protected static function createObjectsFromQuery(array $pdo_result) { diff --git "a/media/Lycoreco\\Apps\\Recipes\\Models\\RecipeModel/chana-masala-recipe_684f4743a2a80.jpg" "b/media/Lycoreco\\Apps\\Recipes\\Models\\RecipeModel/chana-masala-recipe_684f4743a2a80.jpg" new file mode 100644 index 0000000..63d9ff9 Binary files /dev/null and "b/media/Lycoreco\\Apps\\Recipes\\Models\\RecipeModel/chana-masala-recipe_684f4743a2a80.jpg" differ diff --git "a/media/Lycoreco\\Apps\\Recipes\\Models\\RecipeModel/images_684f027f3a8da.jpeg" "b/media/Lycoreco\\Apps\\Recipes\\Models\\RecipeModel/images_684f027f3a8da.jpeg" new file mode 100644 index 0000000..d74d844 Binary files /dev/null and "b/media/Lycoreco\\Apps\\Recipes\\Models\\RecipeModel/images_684f027f3a8da.jpeg" differ diff --git a/media/recipes/Simply-Recipes-Spaghetti-Aglio-e-Olio-LEAD-2-c8e7e8c6edb04a8691463c6ea8cd4ba1_6862601986ee2.jpg b/media/recipes/Simply-Recipes-Spaghetti-Aglio-e-Olio-LEAD-2-c8e7e8c6edb04a8691463c6ea8cd4ba1_6862601986ee2.jpg new file mode 100644 index 0000000..c19273b Binary files /dev/null and b/media/recipes/Simply-Recipes-Spaghetti-Aglio-e-Olio-LEAD-2-c8e7e8c6edb04a8691463c6ea8cd4ba1_6862601986ee2.jpg differ diff --git a/media/recipes/caesar_686a6c042a944.jpg b/media/recipes/caesar_686a6c042a944.jpg new file mode 100644 index 0000000..a8009aa Binary files /dev/null and b/media/recipes/caesar_686a6c042a944.jpg differ diff --git a/media/recipes/chana_685f868b8fb94.jpg b/media/recipes/chana_685f868b8fb94.jpg new file mode 100644 index 0000000..c0380f7 Binary files /dev/null and b/media/recipes/chana_685f868b8fb94.jpg differ diff --git a/media/recipes/garlic_686a6a714284a.jpg b/media/recipes/garlic_686a6a714284a.jpg new file mode 100644 index 0000000..5b8866e Binary files /dev/null and b/media/recipes/garlic_686a6a714284a.jpg differ diff --git a/media/recipes/images_68626081274a5.jpeg b/media/recipes/images_68626081274a5.jpeg new file mode 100644 index 0000000..5093915 Binary files /dev/null and b/media/recipes/images_68626081274a5.jpeg differ diff --git a/urls.php b/urls.php index b07374a..2b2d536 100644 --- a/urls.php +++ b/urls.php @@ -1,15 +1,18 @@ \ No newline at end of file