TIST-17: added single recipe page #15

Merged
steve_dekart merged 2 commits from TIST-17 into develop 2025-06-29 12:25:45 +02:00
9 changed files with 598 additions and 52 deletions

View File

@ -2,7 +2,9 @@
namespace Lycoreco\Apps\Recipes\Controllers; namespace Lycoreco\Apps\Recipes\Controllers;
use Lycoreco\Apps\Recipes\Models\IngredientInRecipeModel;
use Lycoreco\Apps\Recipes\Models\RecipeModel; use Lycoreco\Apps\Recipes\Models\RecipeModel;
use Lycoreco\Apps\Users\Models\UserModel;
use Lycoreco\Includes\BaseController; use Lycoreco\Includes\BaseController;
use Lycoreco\Includes\Routing\HttpExceptions; use Lycoreco\Includes\Routing\HttpExceptions;
@ -33,8 +35,26 @@ class SingleRecipeController extends BaseController
public function get_context_data() public function get_context_data()
{ {
$context = parent::get_context_data(); $context = parent::get_context_data();
$recipe = $this->get_model();
$context['recipe'] = $this->get_model();
$context['recipe'] = $recipe;
$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()
]
));
return $context; return $context;
} }

View File

@ -24,10 +24,10 @@ class IngredientInRecipeModel extends BaseModel
static protected $table_name = 'recipe_ingredients'; static protected $table_name = 'recipe_ingredients';
static protected $table_fields = [ static protected $table_fields = [
'id' => 'int', 'id' => 'int',
'ingredient_id' => 'int', 'ingredient_id' => 'int',
'recipe_id' => 'int', 'recipe_id' => 'int',
'amount' => 'int' 'amount' => 'int'
]; ];
public static function init_table() public static function init_table()
{ {
@ -47,4 +47,9 @@ class IngredientInRecipeModel extends BaseModel
{ {
return $this->field_amount . ' ' . $this->ingredient_unit; return $this->field_amount . ' ' . $this->ingredient_unit;
} }
public function __toString()
{
return $this->ingredient_name;
}
} }

View File

@ -17,7 +17,7 @@ class RecipeModel extends BaseModel
public $category_name; public $category_name;
const STATUS = [[ 'publish', 'Publish' ], [ 'pending', 'Pending' ]]; const STATUS = [['publish', 'Publish'], ['pending', 'Pending']];
static protected $search_fields = ['obj.title']; static protected $search_fields = ['obj.title'];
static protected $table_name = 'recipes'; static protected $table_name = 'recipes';
@ -37,16 +37,16 @@ class RecipeModel extends BaseModel
] ]
); );
static protected $table_fields = [ static protected $table_fields = [
'id' => 'int', 'id' => 'int',
'title' => 'string', 'title' => 'string',
'instruction' => 'string', 'instruction' => 'string',
'image_url' => 'string', 'image_url' => 'string',
'estimated_time' => 'int', 'estimated_time' => 'int',
'estimated_price' => 'float', 'estimated_price' => 'float',
'category_id' => 'int', 'category_id' => 'int',
'author_id' => 'int', 'author_id' => 'int',
'status' => 'string', 'status' => 'string',
'created_at' => 'DateTime' 'created_at' => 'DateTime'
]; ];
public static function init_table() public static function init_table()
{ {
@ -76,10 +76,20 @@ class RecipeModel extends BaseModel
{ {
return ucfirst($this->field_status); return ucfirst($this->field_status);
} }
public function get_image_url() { public function get_image_url()
if($this->field_image_url) {
if ($this->field_image_url)
return MEDIA_URL . $this->field_image_url; return MEDIA_URL . $this->field_image_url;
return null; return null;
} }
public function get_html_instruction(): string
{
return nl2br(trim($this->field_instruction));
}
public function get_time(){
return $this->field_estimated_time . " minutes";
}
} }

View File

@ -31,7 +31,7 @@ the_header(
<i class="fa-solid fa-magnifying-glass search-icon"></i> <i class="fa-solid fa-magnifying-glass search-icon"></i>
<input type="text" class="search-input" placeholder="Search categories..."> <input type="text" class="search-input" placeholder="Search categories...">
</div> </div>
<!-- All labels and ids are the same for template design purposes (all labels point to same checkbox) -->
<div class="filters-checkboxes"> <div class="filters-checkboxes">
<ul> <ul>
<?php foreach ($context['categories'] as $cat): <?php foreach ($context['categories'] as $cat):

View File

@ -1,3 +1,133 @@
<pre> <?php
<?php var_dump($context['recipe']); ?> require_once(INCLUDES_PATH . '/Const/recipes.php');
</pre> the_header(
$context['recipe']->field_title,
'This is a single recipe page where you can view the details of the recipe, including ingredients, instructions, and more.',
'recipe',
[
['keywords', 'terms, use, conditions']
]
);
?>
<div class="container">
<div class="single-recipe">
<div class="single-recipe-info">
<div class="single-recipe-info__image">
<img src="<?= $context['recipe']->get_image_url(); ?>"
alt="<?php echo $context['recipe']->field_title; ?>">
</div>
<div class="single-recipe-info__details">
<div class="single-recipe-title">
<h1 class="title"><?php echo $context['recipe']->field_title; ?></h1>
</div>
<div class="single-recipe-data">
<div class="single-recipe-data__item">
<span class="data-name">Category: </span>
<span class="data"><?= $context['recipe']->category_name ?></span>
</div>
<div class="single-recipe-data__item">
<span class="data-name">Author: </span>
<span class="data"><?= $context['author']->field_username ?></span>
</div>
<div class="single-recipe-data__item">
<span class="data-name">Estimated Price: </span>
<span class="data"><?php echo $context['recipe']->get_price(); ?></span>
</div>
<div class="single-recipe-data__item">
<span class="data-name">Time To Make: </span>
greendavid004 marked this conversation as resolved Outdated

Can you create a new function in the RecipeModel that returns a string combining the estimated_time field and the word “minutes”? I think it will be used repeatedly in the future.

Can you create a new function in the RecipeModel that returns a string combining the estimated_time field and the word “minutes”? I think it will be used repeatedly in the future.
<span class="data"><?php echo $context['recipe']->get_time(); ?></span>
</div>
<div class="single-recipe-data__item">
<span class="data-name">Ingredients: </span>
<span class="data"><?= join(", ", $context['ingredients']) ?></span>
</div>
<div class="single-recipe-data__item">
<span class="data-name">Date Created: </span>
<span class="data"><?php echo $context['recipe']->field_created_at; ?></span>
</div>
</div>
<div class="button-ctrl">
<select class="hidden" name="daily-meal-select" id="daily-meal-day">
<?php foreach (DAYS_OF_WEEK as $day): ?>
<option value="<?= $day ?>"><?= ucfirst($day) ?></option>
<?php endforeach; ?>
<option value="remove">Remove From List</option>
</select>
<div class="day-select-wrapper">
greendavid004 marked this conversation as resolved Outdated

Days of the week can be used in different places, so it would be better to save them as a constant variable to make them reusable.

You can create a new constant variable with all day of weeks in the format ['monday' => 'Monday'], and place it in the Const/ directory as a file named recipes.php (create this directory inside /includes/ if it doesn’t exist).

After that, include it using require_once and use a foreach(DAY_OF_WEKS as $key => $label) to generate the select options.

P.s. Only day of weeks. You don't need add to this array remove. It's not day of week. You can leave that option as it is.

Days of the week can be used in different places, so it would be better to save them as a constant variable to make them reusable. You can create a new constant variable with all day of weeks in the format `['monday' => 'Monday']`, and place it in the `Const/` directory as a file named `recipes.php` (create this directory inside `/includes/` if it doesn’t exist). After that, include it using `require_once` and use a `foreach(DAY_OF_WEKS as $key => $label)` to generate the select options. P.s. Only day of weeks. You don't need add to this array `remove`. It's not day of week. You can leave that option as it is.

Check TIST-27 Pull Request and approve it. After that, then I will merge it with develop, you can make git pull origin develop.

In TIST-27 I have already added this const

Check TIST-27 Pull Request and approve it. After that, then I will merge it with develop, you can make `git pull origin develop`. In TIST-27 I have already added this const
<div class="hover-anim">
<button type="button" href="#" id="day-select" class="btn btn-primary day-select">Add to
list</button>
<i class="fa-solid fa-list select-icon"></i>
</div>
<div id="custom-select-dropdown" class="custom-select-dropdown hidden">
<?php foreach (DAYS_OF_WEEK as $day): ?>
<div class="dropdown-item hover-anim" data-value="<?= $day?>">
<?= ucfirst($day) ?>
</div>
<?php endforeach; ?>
<div class="dropdown-item hover-anim" data-value="remove">Remove From List</div>
</div>
</div>
<div class="small-btns">
greendavid004 marked this conversation as resolved Outdated

Same comment as above

Same comment as above
<button class="btn btn-secondary btn-small hover-anim" title="Add To Favorites">
<i class="fa-regular fa-heart"></i>
</button>
<a class="btn btn-secondary btn-small hover-anim"
href="<?php the_permalink('recipes:export-pdf', [$context['recipe']->get_id()]); ?>"
target="_blank" title="Export As PDF">
<i class="fa-solid fa-download"></i>
</a>
<button class="btn btn-secondary btn-small hover-anim" id="qr-btn" title="Get QR Code">
<i class="fa-solid fa-qrcode"></i>
</button>
</div>
<div id="overlay" class="hidden"></div>
<div class="qr-popup hidden">
<div id="qrcode"></div>
<a id="downloadLink" class="btn btn-primary hover-anim" href="#" download="recipe-qr.png">
Download QR Code
</a>
</div>
</div>
</div>
</div>
<div class="single-recipe-content">
<div class="single-instructions">
<h2 class="title">Instructions</h2>
<?= $context['recipe']->get_html_instruction(); ?>
</div>
<div class="single-ingredients">
<h2 class="title">Ingredients</h2>
<table class="ingredients-table">
<thead>
greendavid004 marked this conversation as resolved Outdated

You can create a new function like get_html_instruction() in the RecipeModel, which returns the formatted instruction (as string).

And it's really important preg_replace('/(\d+\.\s)/', "\n$1", $context['recipe']->field_instruction)? I think you can use only nl2br(trim($formatted));

You can create a new function like `get_html_instruction()` in the `RecipeModel`, which returns the formatted instruction (as string). And it's really important `preg_replace('/(\d+\.\s)/', "\n$1", $context['recipe']->field_instruction)`? I think you can use only `nl2br(trim($formatted));`
<tr>
<th>Name</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<?php
foreach ($context['ingredients'] as $ing) {
?>
<tr>
<td><?= $ing; ?></td>
<td><?= $ing->get_count(); ?></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php the_footer(array(
ASSETS_PATH . '/js/single.js',
)); ?>

View File

@ -662,7 +662,6 @@ hr {
label span { label span {
color: #015847; color: #015847;
/* Example: make the asterisk red */
font-weight: bold; font-weight: bold;
} }
@ -676,7 +675,7 @@ label span {
.btn-secondary { .btn-secondary {
background-color: var(--button-secondary); background-color: var(--button-secondary);
height: 35px; cursor: pointer;
max-width: 225px; max-width: 225px;
color: var(--common-text); color: var(--common-text);
} }
@ -790,7 +789,7 @@ label span {
} }
.filters-checkboxes ul{ .filters-checkboxes ul {
list-style: none; list-style: none;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -801,21 +800,229 @@ label span {
overflow-y: auto; overflow-y: auto;
margin-top: 10px; margin-top: 10px;
margin-bottom: 15px; margin-bottom: 15px;
} }
.filters-checkboxes ul li{ .filters-checkboxes ul li {
font-family: var(--common-font); font-family: var(--common-font);
font-size: 14px; font-size: 14px;
padding: 5px; padding: 5px;
} }
.filters-form .btn{ .filters-form .btn {
width: 200px; width: 200px;
} }
@media (max-width: 768px){
.catalog-items{ .single-recipe {
margin-top: 46px;
}
.single-recipe-title .title {
text-align: start;
}
.single-recipe-info {
display: flex;
}
.single-recipe-info__image {
width: 350px;
height: 260px;
overflow: hidden;
border-radius: 10px;
margin-right: 25px;
}
.single-recipe-info__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.single-recipe-data {
margin-bottom: 20px;
}
.single-recipe-data__item {
display: flex;
align-items: baseline;
margin-bottom: 6px;
}
.single-recipe-data__item .data-name {
color: var(--meta-color);
margin-right: 10px;
flex: 0 0 120px;
text-align: left;
}
.single-recipe-data__item .data {
max-width: 500px;
}
.day-select {
position: relative;
width: 230px;
border: 1px solid #00775F;
padding: 0;
}
.day-select-wrapper {
position: relative;
display: inline-block;
}
.btn-ctrl {
display: flex;
align-items: center;
}
.select-icon {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
background-color: #008C71;
color: #fff;
padding: 10px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.btn-small {
width: 35px;
text-align: center;
padding: 0;
border: 2px solid var(--input-border);
}
.button-ctrl {
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}
.custom-select-dropdown {
position: absolute;
top: 110%;
left: 0;
z-index: 10;
background: white;
border: 1px solid var(--input-border);
border-radius: 10px;
width: 100%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.dropdown-item {
font-size: 16px;
padding: 8px 12px;
cursor: pointer;
border-radius: 10px;
}
.custom-select-dropdown .dropdown-item:last-child {
border-top: 1px solid var(--input-border);
}
.dropdown-selected {
background-color: #00775F;
color: #fff;
}
.qr-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
z-index: 9999;
}
#qrcode img {
margin-bottom: 15px;
}
.hidden {
display: none !important;
}
#overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
.btn-small,
.btn-small i {
line-height: 30px;
}
.single-recipe-content h2 {
text-align: start;
}
.single-recipe-content {
display: flex;
width: 100%;
justify-content: space-between;
align-items: flex-start;
margin-top: 30px;
gap: 40px;
/* flex-wrap: wrap; */
}
.single-instructions{
width: 50%;
font-size: 16px;
}
.single-ingredients{
width: 50%;
}
.ingredients-table{
width: 400px;
border-radius: 10px;
overflow: hidden;
border: 1px solid #b3b3b3;
border-collapse: separate;
}
.ingredients-table th{
background-color: #008B70;
color: #fff;
padding: 10px 20px;
text-align: start;
}
.ingredients-table td{
padding: 10px 20px;
border-top: 2px solid #b3b3b3;
}
@media (max-width: 768px) {
.catalog-items {
width: 100%; width: 100%;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
margin-right: 0; margin-right: 0;
@ -824,7 +1031,104 @@ label span {
flex-direction: column; flex-direction: column;
} }
.single-recipe-content {
flex-wrap: wrap;
flex-direction: column;
gap: 20px;
padding: 10px;
}
.single-instructions,
.single-ingredients {
width: 100%;
}
.ingredients-table {
width: 100%;
}
.single-recipe-info {
flex-direction: column;
align-items: center;
text-align: center;
}
.single-recipe-info__image {
width: 100%;
max-width: 350px;
height: auto;
margin-right: 0;
margin-bottom: 20px;
}
.single-recipe-info__image img {
width: 100%;
height: auto;
}
.single-recipe-info__details {
width: 100%;
padding: 0 15px;
}
.single-recipe-title .title {
text-align: center;
font-size: 24px;
}
.single-recipe-data__item {
flex-direction: column;
align-items: flex-start;
margin-bottom: 10px;
}
.single-recipe-data__item .data-name {
margin-right: 0;
margin-bottom: 2px;
flex: none;
text-align: left;
}
.button-ctrl {
flex-direction: column;
align-items: center;
gap: 12px;
}
.small-btns{
display: flex;
justify-content: center;
gap: 10px;
}
.day-select-wrapper,
.btn-small,
.btn-small i {
width: auto;
}
.btn-small{
width: 35px;
}
.custom-select-dropdown {
width: 100%;
left: 0;
max-width: 230px;
}
.qr-popup {
width: 90%;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@ -904,42 +1208,47 @@ label span {
max-width: 100%; max-width: 100%;
} }
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.catalog{ .catalog {
flex-direction: column-reverse; flex-direction: column-reverse;
align-items: center; align-items: center;
margin-bottom: 46px; margin-bottom: 46px;
} }
.filters{
.filters {
position: static; position: static;
margin-bottom: 46px; margin-bottom: 46px;
} }
.catalog-items{
.catalog-items {
margin-right: 0; margin-right: 0;
justify-items: center; justify-items: center;
align-items: center; align-items: center;
padding: 5px; padding: 5px;
} }
.catalog-recipe{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 200px;
}
}
@media (max-width: 465px){
.catalog-recipe{
width: 150px;
.catalog-recipe {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 200px;
} }
#food-3d { #food-3d {
display: none; display: none;
} }
} }
@media (max-width: 400px){ @media (max-width: 465px) {
.catalog-items{ .catalog-recipe {
width: 150px;
}
}
@media (max-width: 400px) {
.catalog-items {
width: 100%; width: 100%;
grid-template-columns: 1fr; grid-template-columns: 1fr;
margin-right: 0; margin-right: 0;

71
assets/js/single.js Normal file
View File

@ -0,0 +1,71 @@
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');
options.forEach(option => {
option.addEventListener('click', () => {
const selectedValue = option.getAttribute('data-value');
options.forEach(opt => opt.classList.remove('dropdown-selected'));
if (selectedValue === 'remove') {
toggleBtn.textContent = 'Add to list';
} else {
toggleBtn.textContent = option.textContent;
option.classList.add('dropdown-selected');
}
dropdown.classList.add('hidden');
alert(`You selected: ${selectedValue}`);
});
});
const qrContainer = document.getElementById("qrcode");
const downloadLink = document.getElementById("downloadLink");
const qrBtn = document.getElementById("qr-btn");
const qrPopup = document.querySelector(".qr-popup");
greendavid004 marked this conversation as resolved Outdated

This is not poppup, this is modal.

Of course, you can leave it, but I think you need to create your own solution for modal. It's small difficulty, but after you can use this modal everywhere.

So, every modal has same codebase:

<div id="overlay" class="hidden"></div>
<div class="qr-popup hidden"> <!-- it's not puppu, it's modal -->
 <!-- Content -->
</div>

So default behavior for every modal you can implement in the main.js. After that, make some events for it. If you need more details, I can explain how to do it

This is not poppup, this is modal. Of course, you can leave it, but I think you need to create your own solution for modal. It's small difficulty, but after you can use this modal everywhere. So, every modal has same codebase: ``` <div id="overlay" class="hidden"></div> <div class="qr-popup hidden"> <!-- it's not puppu, it's modal --> <!-- Content --> </div> ``` So default behavior for every modal you can implement in the `main.js`. After that, make some events for it. If you need more details, I can explain how to do it

We can talk about it on Discord

We can talk about it on Discord
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");
}
});

1
assets/qrcode/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -20,7 +20,7 @@
<script src="<?php echo ASSETS_PATH . '/js/main.js' ?>"></script> <script src="<?php echo ASSETS_PATH . '/js/main.js' ?>"></script>
<script src="<?php echo ASSETS_PATH . '/toastify/toastify-js.js' ?>"></script> <script src="<?php echo ASSETS_PATH . '/toastify/toastify-js.js' ?>"></script>
<script src="<?php echo ASSETS_PATH . '/qrcode/qrcode.min.js' ?>"></script>
greendavid004 marked this conversation as resolved Outdated

I think you need to save this file in the ASSETS_PATH like toastify.js.

I think you need to save this file in the ASSETS_PATH like `toastify.js`.
<?php foreach($scripts as $script): ?> <?php foreach($scripts as $script): ?>
<script src="<?php echo $script ?>"></script> <script src="<?php echo $script ?>"></script>
<?php endforeach; ?> <?php endforeach; ?>