Compare commits

...

4 Commits

Author SHA1 Message Date
David Katrinka
76d4011c41 fixed all issues other than modal 2025-06-29 12:11:07 +02:00
David Katrinka
c58a94d23c TIST-17: added single recipe page 2025-06-29 12:11:07 +02:00
703a327f39 Merge pull request 'TIST-29: ThreeJS' (#18) from TIST-29 into develop
Reviewed-on: #18
Reviewed-by: greendavid004 <davidkatrinka1995@gmail.com>
2025-06-29 11:58:00 +02:00
3959a8c55a Added 3d model and customized first block on index page 2025-06-29 11:39:17 +02:00
12 changed files with 722 additions and 60 deletions

View File

@ -8,13 +8,23 @@
] ]
); );
?> ?>
<div class="welcome">
<div class="container">
<div class="welcome__inner">
<div class="text-container">
<div class="quote">
<h2><span class="black-qoute-word">Your</span> Fridge.</h2>
<h2><span class="black-qoute-word">Your</span> Rules.</h2>
<h2><span class="black-qoute-word">Our</span> Recipes.</h2>
</div>
<p>Discover delicious recipes tailored to exactly what you have on hand no extra shopping trips, no wasted food, just tasty meals made easy.</p>
</div>
<canvas id="food-3d"></canvas>
</div>
</div>
</div>
<div class="container"> <div class="container">
<div class="quote">
<h2><span class="black-qoute-word">Your</span> Fridge. <span class="black-qoute-word">Your</span> Rules.
<span class="black-qoute-word">Our</span> Recipes.
</h2>
</div>
<div class="latest-recipes"> <div class="latest-recipes">
<h2 class="title">Latest Recipes Added</h2> <h2 class="title">Latest Recipes Added</h2>
<!-- Slider main container --> <!-- Slider main container -->
@ -402,6 +412,17 @@
</div> </div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@v0.149.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@v0.149.0/examples/jsm/"
}
}
</script>
<script type="module" src="<?= ASSETS_PATH . '/js/threejs-scene.js' ?>"></script>
<?php the_footer(array( <?php the_footer(array(
ASSETS_PATH . '/swiper/swiper-bundle.min.js', ASSETS_PATH . '/swiper/swiper-bundle.min.js',
ASSETS_PATH . '/js/index.js', ASSETS_PATH . '/js/index.js',

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>
<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">
<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">
<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>
<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

@ -148,14 +148,35 @@ body {
} }
.quote { .quote {
display: flex;
flex-wrap: wrap;
font-family: var(--common-font); font-family: var(--common-font);
font-size: 20px; font-size: 20px;
color: var(--panel-text); color: var(--panel-text);
margin: 0 auto;
margin-top: 46px;
margin-bottom: 30px;
text-align: center; text-align: center;
}
.quote h2 {
margin-right: 10px;
margin-bottom: 5px;
}
.welcome {
background: var(--panel-background);
margin-bottom: 30px;
font-size: 24px;
line-height: 32px;
}
.welcome__inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome__inner .quote {
font-size: 38px;
text-align: left;
margin-bottom: 20px;
}
.welcome .text-container {
padding: 50px 0;
} }
.black-qoute-word { .black-qoute-word {
@ -641,7 +662,6 @@ hr {
label span { label span {
color: #015847; color: #015847;
/* Example: make the asterisk red */
font-weight: bold; font-weight: bold;
} }
@ -655,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);
} }
@ -769,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;
@ -780,27 +800,335 @@ 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;
} }
.welcome__inner {
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) {
@ -880,39 +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{
.catalog-recipe {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 200px; width: 200px;
}
#food-3d {
display: none;
} }
} }
@media (max-width: 465px){
.catalog-recipe{ @media (max-width: 465px) {
.catalog-recipe {
width: 150px; width: 150px;
} }
} }
@media (max-width: 400px){ @media (max-width: 400px) {
.catalog-items{ .catalog-items {
width: 100%; width: 100%;
grid-template-columns: 1fr; grid-template-columns: 1fr;
margin-right: 0; margin-right: 0;

BIN
assets/food_3d.glb Normal file

Binary file not shown.

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");
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");
}
});

View File

@ -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();

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>
<?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; ?>