Merge develop into feature/Show-ShoppingList-Of-Active-RecipeCard

This commit is contained in:
Francesco Lorenzo D'Amico
2025-06-04 10:59:20 +02:00
24 changed files with 1419 additions and 140 deletions
@@ -0,0 +1,77 @@
@model Francesco.Recipes.World.Models.FavoriteViewModel
@{
ViewData["Title"] = "Favoriten";
}
<h2 class="mb-4">Favoriten</h2>
<div class="mb-4 text-end">
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="sortDropdown"
data-bs-toggle="dropdown" aria-expanded="false">
@Model.SortOrderDisplayText
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="sortDropdown">
<li>
<a class="dropdown-item @(Model.SortOrder == "newest" ? "active" : "")"
href="@Url.Action("Index", new { sortOrder = "newest" })">Neueste Favorits</a>
</li>
<li>
<a class="dropdown-item @(Model.SortOrder == "oldest" ? "active" : "")"
href="@Url.Action("Index", new { sortOrder = "oldest" })">Älteste Favorits</a>
</li>
</ul>
</div>
</div>
@if (!Model.HasFavorites)
{
<div class="alert alert-info">
Keine Favoriten vorhanden. Füge Rezepte zu deinen Favoriten hinzu, indem du auf den Stern klickst.
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-3 g-4">
@foreach (var recipe in Model.FavoriteRecipes)
{
<div class="col">
<div class="card h-100">
@{
var mediaFile = recipe.MediaFiles?.FirstOrDefault(m => m.MimeType != null && m.MimeType.StartsWith("image/"));
var imageData = mediaFile?.Data;
var mimeType = mediaFile?.MimeType;
}
<div class="card-img-top text-center bg-light" style="height:200px; display:flex; align-items:center; justify-content:center;">
@if (imageData != null && mimeType != null)
{
<img src="data:@mimeType;base64,@Convert.ToBase64String(imageData)"
alt="@recipe.Name" class="img-fluid" style="max-height:100%; max-width:100%; object-fit:contain;" />
}
else
{
<div class="text-secondary">Kein Bild</div>
}
</div>
<div class="card-body">
<h5 class="card-title">@recipe.Name</h5>
<div class="d-flex align-items-center mt-3">
<span class="me-3">
@await Html.PartialAsync("_FavoriteButton", recipe)
</span>
<span class="text-muted">
<i class="bi bi-clock"></i> @(recipe.PreparationTime.TotalMinutes + recipe.CookingTime.TotalMinutes)min
</span>
</div>
</div>
<div class="card-footer bg-white">
<a href="@Url.Action("Details", "Recipe", new { recipeId = recipe.Id })"
class="btn btn-outline-primary btn-sm w-100">Details</a>
</div>
</div>
</div>
}
</div>
}
+37 -39
View File
@@ -1,42 +1,42 @@
@model IEnumerable<Francesco.Recipes.World.Views.Category.CategoryRecipesViewModel>
@Html.AntiForgeryToken()
@Html.AntiForgeryToken()
<div class="welcome-banner text-center mb-4">
<img src="/images/banner2.jpg" alt="Willkommen" class="img-fluid" />
<h1 class="mt-3">Willkommen in der Rezept-App</h1>
</div>
<div class="welcome-banner text-center mb-4">
<img src="/images/banner2.jpg" alt="Willkommen" class="img-fluid" />
<h1 class="mt-3">Willkommen in der Rezept-App</h1>
</div>
<form hx-get="/Home/Search"
hx-target="#search-results"
hx-trigger="keyup changed delay:300ms"
onsubmit="return false;"
class="mb-4">
<input type="text" name="query" class="form-control" placeholder="Suchen nach Rezepten oder Zutaten..." autocomplete="off" />
</form>
<form hx-get="/Home/Search"
hx-target="#search-results"
hx-trigger="keyup changed delay:300ms"
onsubmit="return false;"
class="mb-4">
<input type="text" name="term" class="form-control" placeholder="Suchen nach Rezepten oder Zutaten..." autocomplete="off" />
</form>
<div id="search-results" class="mt-4"></div>
<div id="search-results" class="mt-4"></div>
@foreach (var category in Model)
{
<div class="category-section mb-5" id="category-@category.Category.Id">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>@category.Category.Name</h2>
<a href="/Category/Details/@category.Category.Id"
class="btn btn-link"
hx-get="/Category/Details/@category.Category.Id"
hx-target="#category-@category.Category.Id"
hx-swap="outerHTML">Alle @category.Category.Name-Rezepte anzeigen</a>
</div>
@foreach (var category in Model)
{
<div class="category-section mb-5" id="category-@category.Category.Id">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>@category.Category.Name</h2>
<a href="/Category/Details/@category.Category.Id"
class="btn btn-link"
hx-get="/Category/Details/@category.Category.Id"
hx-target="#category-@category.Category.Id"
hx-swap="outerHTML">Alle @category.Category.Name-Rezepte anzeigen</a>
</div>
<div class="row">
@foreach (var recipe in category.Recipes)
{
var mediaFile = recipe.MediaFiles?.FirstOrDefault();
var imageData = mediaFile?.Data;
var mimeType = mediaFile?.MimeType;
<div class="row">
@foreach (var recipe in category.Recipes)
{
var mediaFile = recipe.MediaFiles?.FirstOrDefault();
var imageData = mediaFile?.Data;
var mimeType = mediaFile?.MimeType;
<div class="col-md-3 mb-4">
<div class="card h-100">
@@ -66,16 +66,14 @@
</div>
}
<div class="col-md-3 mb-4">
<div class="card h-100 text-center">
<div class="card-body d-flex flex-column justify-content-center">
<a href="/Create/@category.Category.Id"
class="btn btn-outline-primary">Rezept hinzufügen</a>
<div class="col-md-3 mb-4">
<div class="card h-100 text-center">
<div class="card-body d-flex flex-column justify-content-center">
<a href="/Create/@category.Category.Id"
class="btn btn-outline-primary">Rezept hinzufügen</a>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
@@ -1,58 +1,75 @@
@model IEnumerable<Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe>
@model IEnumerable<Francesco.Recipes.World.Models.SearchViewModel>
@{
if (!Model.Any())
{
<p class="text-muted">Keine Ergebnisse gefunden.</p>
}
else
{
<div class="row">
@foreach (var recipe in Model)
{
<div class="col-md-4 mb-3">
<div class="card h-100 shadow-sm">
<div class="recipe-image">
@{
var mediaFile = recipe.MediaFiles.FirstOrDefault();
if (mediaFile?.Data != null)
{
<img src="data:@mediaFile.MimeType;base64,@Convert.ToBase64String(mediaFile.Data)"
alt="@recipe.Name"
class="card-img-top" />
}
else
{
<img src="~/images/placeholder.png"
alt="Platzhalter"
class="card-img-top" />
}
}
</div>
<div class="card-body d-flex flex-column justify-content-between">
<div>
<h5 class="card-title">@recipe.Name</h5>
<p class="card-text text-muted mb-2">
<i class="bi bi-clock"></i>
@recipe.PreparationTime.Hours h @recipe.PreparationTime.Minutes min
</p>
{
<p class="text-muted">Keine Ergebnisse gefunden.</p>
}
else
{
<div class="row">
@foreach (var recipe in Model)
{
<div class="col-md-4 mb-3">
<div class="card h-100 shadow-sm">
<div class="recipe-image">
@if (recipe.ImageData != null && recipe.MimeType != null)
{
<img src="data:@recipe.MimeType;base64,@Convert.ToBase64String(recipe.ImageData)"
alt="@recipe.Name"
class="card-img-top" />
}
else
{
<img src="~/images/placeholder.png"
alt="Platzhalter"
class="card-img-top" />
}
</div>
<div>
<button class="btn btn-outline-secondary"
hx-post="/Recipe/ToggleFavorite"
hx-vals='{"id": "@recipe.Id"}'
hx-swap="outerHTML">
<i class="bi @((recipe.IsFavorite) ? "bi-star-fill" : "bi-star")"></i>
</button>
<div class="card-body d-flex flex-column justify-content-between">
<div>
<h5 class="card-title">@recipe.Name</h5>
<p class="card-text text-muted mb-2">
<i class="bi bi-clock"></i>
@recipe.TotalTime.Hours h @recipe.TotalTime.Minutes min
</p>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="favorite-button-container">
<form hx-post="@Url.Action(recipe.IsFavorite ? "RemoveFavorite" : "AddFavorite", "Recipe")"
hx-target="this"
hx-swap="outerHTML">
@Html.AntiForgeryToken()
<input type="hidden" name="recipeId" value="@recipe.Id" />
<button type="submit" class="btn btn-link p-0" style="width: 40px; height: 40px;">
@if (recipe.IsFavorite)
{
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFD700">
<path d="M12 17.27L18.18 21 16.54 13.97
22 9.24l-7.19-.62L12 2 9.19 8.62
2 9.24l5.46 4.73L5.82 21z" />
</svg>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17.27L18.18 21 16.54 13.97
22 9.24l-7.19-.62L12 2 9.19 8.62
2 9.24l5.46 4.73L5.82 21z" />
</svg>
}
</button>
</form>
</div>
<a href="/Recipe/Details/@recipe.Id" class="btn btn-primary btn-sm">Details</a>
</div>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
}
}
@@ -6,7 +6,7 @@
<h1>@Model.Name</h1>
<div class="recipe-details">
<div class="recipe-details" data-recipe-id="@Model.Id">
<div class="recipe-image">
@if (Model.MediaFiles.Any() && Model.MediaFiles.First().Data != null)
{
@@ -20,34 +20,166 @@
<div class="recipe-info">
<p><strong>Description:</strong> @Model.Description</p>
<p><strong>Difficulty:</strong> @Model.Difficulty</p>
<p><strong>Servings:</strong> @Model.Servings</p>
<p><strong>Preparation Time:</strong> @Model.PreparationTime</p>
<p><strong>Cooking Time:</strong> @Model.CookingTime</p>
</div>
<div class="recipe-ingredients">
<h3>Ingredients</h3>
<form id="ingredient-form">
@Html.AntiForgeryToken()
<ul id="ingredient-list">
@foreach (var ingredient in Model.RecipeIngredients)
{
<li id="ingredient-@ingredient.Id">
<input type="checkbox" name="ingredientIds" value="@ingredient.Id" />
@ingredient.Ingredient.Name - @ingredient.Quantity @(ingredient.Unit != null ? ingredient.Unit.Symbol : string.Empty)
</li>
}
</ul>
<button type="button" class="btn btn-primary" onclick="addSelectedIngredientsToShoppingList()">Add Selected to Shopping List</button>
</form>
</div>
@await Html.PartialAsync("_AdjustableIngredientsPartial", Model)
@await Html.PartialAsync("_RecipeInstructionGridPartial", new Francesco.Recipes.World.Models.InstructionViewModel
{
RecipeId = Model.Id,
Instructions = Model.Instructions.ToList()
})
<form hx-post="@($"/{Model.Id}/Delete")"
hx-confirm="Sind Sie sicher, dass Sie dieses Rezept löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
hx-redirect="/">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Rezept löschen
</button>
</form>
</div>
@section Scripts {
<script>
async function addSelectedIngredientsToShoppingList() {
var form = document.getElementById('ingredient-form');
var formData = new FormData(form);
var selectedIngredientIds = formData.getAll('ingredientIds');
document.addEventListener('DOMContentLoaded', function() {
const recipeId = '@Model.Id';
window.recipeId = recipeId;
const originalServings = @Model.Servings;
const specialUnits = {
discreteUnits: ["stk", "zehe", "blatt", "bund", "stange"],
smallUnits: ["msp"],
spoonUnits: {
"TL": { name: "teelöffel", toMl: 5 },
"EL": { name: "esslöffel", toMl: 15 }
}
};
const conversionTable = {
"knoblauch": {
unit: "zehe",
smallAmount: 0.5
},
};
const savedServings = localStorage.getItem(`recipe_${recipeId}_servings`);
if (savedServings) {
document.getElementById('servingsInput').value = savedServings;
adjustIngredientQuantities(parseInt(savedServings));
}
document.getElementById('decreaseServings').addEventListener('click', function () {
const input = document.getElementById('servingsInput');
const currentValue = parseInt(input.value);
if (currentValue > 1) {
input.value = currentValue - 1;
adjustIngredientQuantities(currentValue - 1);
saveServingsToLocalStorage(currentValue - 1);
}
});
document.getElementById('increaseServings').addEventListener('click', function () {
const input = document.getElementById('servingsInput');
const currentValue = parseInt(input.value);
input.value = currentValue + 1;
adjustIngredientQuantities(currentValue + 1);
saveServingsToLocalStorage(currentValue + 1);
});
document.getElementById('servingsInput').addEventListener('change', function () {
const newServings = parseInt(this.value);
if (newServings < 1) {
this.value = 1;
adjustIngredientQuantities(1);
saveServingsToLocalStorage(1);
} else {
adjustIngredientQuantities(newServings);
saveServingsToLocalStorage(newServings);
}
});
function adjustIngredientQuantities(newServings) {
const ingredients = document.querySelectorAll('#adjustable-ingredient-list li');
ingredients.forEach(ingredient => {
const originalQuantity = parseFloat(ingredient.getAttribute('data-original-quantity'));
const ingredientName = ingredient.getAttribute('data-ingredient-name').toLowerCase();
const unitSymbol = ingredient.getAttribute('data-unit');
const unitName = ingredient.getAttribute('data-unit-name');
let adjustedQuantity = (originalQuantity / originalServings) * newServings;
let finalQuantity = adjustedQuantity;
let note = "";
if (specialUnits.discreteUnits.includes(unitSymbol)) {
if (adjustedQuantity < 1 && adjustedQuantity > 0) {
finalQuantity = Math.ceil(adjustedQuantity);
note = " (ggf. eine kleine/halbe nehmen)";
} else {
finalQuantity = Math.round(adjustedQuantity);
}
}
// Convert small unit "msp" (pinch) to teaspoons (TL) for a better estimate
// 4 pinches = about 1 TL → show that as a note, rounded to 1 decimal
else if (specialUnits.smallUnits.includes(unitSymbol)) {
if (adjustedQuantity > 3) {
finalQuantity = Math.round(adjustedQuantity / 4 * 10) / 10;
note = ` (ca. ${finalQuantity} TL)`;
finalQuantity = adjustedQuantity;
} else {
finalQuantity = Math.round(adjustedQuantity);
}
}
else if (Object.keys(specialUnits.spoonUnits).includes(unitSymbol)) {
if (adjustedQuantity > 4 && unitSymbol === "TL") {
const esslöffel = Math.round(adjustedQuantity / 3 * 10) / 10;
note = ` (ca. ${esslöffel} EL)`;
}
finalQuantity = Math.round(adjustedQuantity * 2) / 2;
}
if (ingredientName.includes("knoblauch") && unitSymbol === "zehe" && adjustedQuantity < 1) {
finalQuantity = 1;
note = " (kleine Zehe)";
}
const formattedQuantity = formatQuantity(finalQuantity);
ingredient.querySelector('.ingredient-quantity').textContent = formattedQuantity;
const noteElement = ingredient.querySelector('.ingredient-note');
if (noteElement) {
noteElement.textContent = note;
}
});
}
function saveServingsToLocalStorage(servings) {
localStorage.setItem(`recipe_${recipeId}_servings`, servings.toString());
}
function formatQuantity(quantity) {
if (Number.isInteger(quantity)) {
return quantity;
}
if (quantity === 0.5 || quantity === 0.25 || quantity === 0.75) {
return quantity;
}
return Math.round(quantity * 10) / 10;
}
window.addSelectedIngredientsToShoppingList = function(recipeIdParam) {
const idToUse = recipeIdParam || recipeId;
var form = document.getElementById('ingredient-form');
var formData = new FormData(form);
var selectedIngredientIds = formData.getAll('ingredientIds');
var token = document.querySelector('input[name="__RequestVerificationToken"]').value;
@@ -0,0 +1,36 @@
@model Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe
<div class="adjustable-ingredients">
<div class="serving-adjustment mb-3" data-original-servings="@Model.Servings">
<h3>Ingredients</h3>
<div class="d-flex align-items-center mb-2">
<label for="servingsInput" class="me-2">Adjust servings:</label>
<div class="input-group" style="max-width: 150px;">
<button type="button" class="btn btn-outline-secondary" id="decreaseServings">-</button>
<input type="number" class="form-control text-center" id="servingsInput" value="@Model.Servings" min="1">
<button type="button" class="btn btn-outline-secondary" id="increaseServings">+</button>
</div>
<span class="ms-2 text-muted">(Original: @Model.Servings)</span>
</div>
</div>
<form id="ingredient-form">
<ul id="adjustable-ingredient-list">
@foreach (var ingredient in Model.RecipeIngredients)
{
<li id="ingredient-@ingredient.Id"
data-ingredient-id="@ingredient.Id"
data-original-quantity="@ingredient.Quantity"
data-ingredient-name="@ingredient.Ingredient.Name"
data-unit="@(ingredient.Unit?.Symbol ?? string.Empty)">
<input type="checkbox" name="ingredientIds" value="@ingredient.Id" />
<span class="ingredient-name">@ingredient.Ingredient.Name</span> -
<span class="ingredient-quantity">@ingredient.Quantity</span>
<span class="ingredient-unit">@(ingredient.Unit != null ? ingredient.Unit.Symbol : string.Empty)</span>
<span class="ingredient-note"></span>
</li>
}
</ul>
<button type="button" class="btn btn-primary mt-2" onclick="addSelectedIngredientsToShoppingList('@Model.Id')">Add Selected to Shopping List</button>
</form>
</div>
@@ -0,0 +1,31 @@
@model Francesco.Recipes.World.Models.InstructionViewModel
<div class="recipe-instructions-section">
<h3>Instructions</h3>
<div class="instructions-grid">
@foreach (var instruction in Model.Instructions.OrderBy(i => i.Number))
{
<div class="instruction-card">
<div class="instruction-image">
@if (instruction.MediaFiles != null && instruction.MediaFiles.Any())
{
var mediaFile = instruction.MediaFiles.First();
if (mediaFile.Data != null && mediaFile.Data.Length > 0)
{
<img src="data:@mediaFile.MimeType;base64,@Convert.ToBase64String(mediaFile.Data)" alt="Step @instruction.Number" />
}
}
else
{
<div class="placeholder-image">
<i class="bi bi-image"></i>
</div>
}
<span class="step-number">@instruction.Number</span>
</div>
<p class="instruction-text">@instruction.Description</p>
</div>
}
</div>
</div>