Merge branch 'feature/Create-Recipe' into 'develop'

Create-Recipe

See merge request francesco.damico/francescos.recipes.world!14
This commit is contained in:
Christian Hunziker
2025-05-07 08:06:28 +00:00
26 changed files with 1438 additions and 273 deletions
@@ -15,7 +15,7 @@
// POST: /UploadImage // POST: /UploadImage
[HttpPost("UploadImage")] [HttpPost("UploadImage")]
[AutoValidateAntiforgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> UploadImage(Guid recipeId, IFormFile? mediaFile) public async Task<IActionResult> UploadImage(Guid recipeId, IFormFile? mediaFile)
{ {
if (mediaFile is null) if (mediaFile is null)
@@ -42,7 +42,7 @@
} }
[HttpPost("ReplaceInstructionImage")] [HttpPost("ReplaceInstructionImage")]
[AutoValidateAntiforgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ReplaceInstructionImage(Guid instructionId, Guid mediaFileIdToReplace, IFormFile? newPhoto) public async Task<IActionResult> ReplaceInstructionImage(Guid instructionId, Guid mediaFileIdToReplace, IFormFile? newPhoto)
{ {
if (newPhoto is null) if (newPhoto is null)
@@ -67,5 +67,31 @@
} }
} }
} }
[HttpPost("Recipe/{recipeId}/Instruction/{instructionId}/UploadImage")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadInstructionImage(Guid recipeId, Guid instructionId, IFormFile? photo)
{
if (recipeId == Guid.Empty)
{
return BadRequest("Recipe ID is required.");
}
if (photo == null)
{
return BadRequest("Photo is required.");
}
try
{
await _mediaFileRepository.UploadInstructionImageAsync(instructionId, photo);
return RedirectToAction("GetInstructions", "Instruction", new { recipeId });
}
catch (Exception ex)
{
return StatusCode(500, $"Internal server error: {ex.Message}");
}
}
} }
} }
@@ -1,5 +1,9 @@
namespace Francesco.Recipes.World.Controller.Recipe namespace Francesco.Recipes.World.Controller.Recipe
{ {
using Francesco.Recipes.World.Data;
using Francesco.Recipes.World.Models;
using Francesco.Recipes.World.Models.BackendModels.Ingredient;
using Francesco.Recipes.World.Models.BackendModels.Instruction;
using Francesco.Recipes.World.Models.BackendModels.Recipe; using Francesco.Recipes.World.Models.BackendModels.Recipe;
using Francesco.Recipes.World.Repositories.Category; using Francesco.Recipes.World.Repositories.Category;
using Francesco.Recipes.World.Repositories.Favorit; using Francesco.Recipes.World.Repositories.Favorit;
@@ -22,6 +26,7 @@
private readonly IMediaFileRepository _mediaFileRepository; private readonly IMediaFileRepository _mediaFileRepository;
private readonly IInstructionRepository _instructionRepository; private readonly IInstructionRepository _instructionRepository;
private readonly IFavoriteRepository _favoriteRepository; private readonly IFavoriteRepository _favoriteRepository;
private readonly FrancescosRecipesWorldDbContext _context;
public IReadOnlyCollection<Recipe> Recipes { get; set; } public IReadOnlyCollection<Recipe> Recipes { get; set; }
@@ -32,7 +37,8 @@
IIngredientRepository ingredientRepository, IIngredientRepository ingredientRepository,
IMediaFileRepository mediaFileRepository, IMediaFileRepository mediaFileRepository,
IInstructionRepository instructionRepository, IInstructionRepository instructionRepository,
IFavoriteRepository favoriteRepository) IFavoriteRepository favoriteRepository,
FrancescosRecipesWorldDbContext context)
{ {
_recipeRepository = recipeRepository; _recipeRepository = recipeRepository;
_unitRepository = unitRepository; _unitRepository = unitRepository;
@@ -42,16 +48,30 @@
_mediaFileRepository = mediaFileRepository; _mediaFileRepository = mediaFileRepository;
_instructionRepository = instructionRepository; _instructionRepository = instructionRepository;
_favoriteRepository = favoriteRepository; _favoriteRepository = favoriteRepository;
_context = context;
} }
// GET: /Recipe/{recipeId}/AddOrCreateIngredient // GET: /Recipe/{recipeId}/AddOrCreateIngredient
[HttpGet("{recipeId}/AddOrCreateIngredient")] [HttpGet("{recipeId}/AddOrCreateIngredient")]
public async Task<IActionResult> AddOrCreateIngredient(Guid recipeId) public async Task<IActionResult> AddOrCreateIngredient(Guid recipeId)
{ {
if (recipeId == Guid.Empty)
{
return BadRequest("Recipe ID cannot be empty.");
}
var units = await _unitRepository.GetAllUnitsAsync(); var units = await _unitRepository.GetAllUnitsAsync();
ViewBag.Units = new SelectList(units, "Id", "Name"); var recipeIngredients = await _ingredientRepository.GetIngredientsByRecipeIdAsync(recipeId);
ViewBag.RecipeId = recipeId; var ingredients = recipeIngredients.Select(ri => ri.Ingredient).ToList();
return View();
var viewModel = new IngredientViewModel
{
RecipeId = recipeId,
Ingredients = ingredients,
Units = units.ToList(),
};
return View(viewModel);
} }
// GET: /Recipe/Details/{recipeId} // GET: /Recipe/Details/{recipeId}
@@ -87,6 +107,11 @@
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AddOrCreateIngredient(Guid recipeId, string ingredientName, int quantity, Guid unitId) public async Task<IActionResult> AddOrCreateIngredient(Guid recipeId, string ingredientName, int quantity, Guid unitId)
{ {
if (recipeId == Guid.Empty)
{
return BadRequest("Recipe ID cannot be empty.");
}
if (quantity <= 0) if (quantity <= 0)
{ {
ModelState.AddModelError(nameof(quantity), "Die Menge muss größer als 0 sein."); ModelState.AddModelError(nameof(quantity), "Die Menge muss größer als 0 sein.");
@@ -99,7 +124,7 @@
return View(); return View();
} }
await _recipeRepository.AddOrCreateIngredientToRecipeAsync(recipeId, ingredientName, quantity, unitId); await _recipeRepository.CreateRecipeIngredientAsync(recipeId, ingredientName, quantity, unitId);
return RedirectToAction("Details", new { id = recipeId }); return RedirectToAction("Details", new { id = recipeId });
} }
@@ -113,23 +138,45 @@
return NotFound($"Kategorie mit ID {categoryId} wurde nicht gefunden."); return NotFound($"Kategorie mit ID {categoryId} wurde nicht gefunden.");
} }
ViewBag.CategoryName = category.Name; var units = await _unitRepository.GetAllUnitsAsync();
return View();
var viewModel = new CreateRecipeViewModel
{
CategoryId = categoryId,
CategoryName = category.Name,
IngredientViewModel = new IngredientViewModel
{
RecipeId = Guid.Empty,
Ingredients = new List<Ingredient>(),
Units = units.ToList(),
},
InstructionViewModel = new InstructionViewModel
{
RecipeId = Guid.Empty,
Instructions = new List<Instruction>(),
},
};
return View(viewModel);
} }
// POST: /Recipe/Create/{categoryId} // POST: /Recipe/Create/{categoryId}
[HttpPost("Create/{categoryId}")] [HttpPost("Create/{categoryId}")]
[AutoValidateAntiforgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Guid categoryId, string name, string description, Difficulty difficulty, int servings, TimeSpan preparationTime, TimeSpan cookingTime, IFormFile? photo) public async Task<IActionResult> Create(Guid categoryId, CreateRecipeViewModel model)
{ {
if (string.IsNullOrWhiteSpace(name)) if (model == null)
{ {
ModelState.AddModelError(nameof(name), "Name darf nicht leer sein."); throw new ArgumentNullException(nameof(model), "CreateRecipeViewModel cannot be null.");
} }
if (servings <= 0) if (string.IsNullOrWhiteSpace(model.Name))
{ {
ModelState.AddModelError(nameof(servings), "Anzahl der Portionen muss größer als 0 sein."); ModelState.AddModelError(nameof(model.Name), "Name darf nicht leer sein.");
}
if (model.Servings <= 0)
{
ModelState.AddModelError(nameof(model.Servings), "Anzahl der Portionen muss größer als 0 sein.");
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)
@@ -140,24 +187,104 @@
return NotFound($"Kategorie mit ID {categoryId} wurde nicht gefunden."); return NotFound($"Kategorie mit ID {categoryId} wurde nicht gefunden.");
} }
ViewBag.CategoryName = category.Name; var units = await _unitRepository.GetAllUnitsAsync();
return View();
if (model.IngredientViewModel == null)
{
model.IngredientViewModel = new IngredientViewModel
{
RecipeId = Guid.Empty,
Ingredients = new List<Ingredient>(),
Units = units.ToList(),
};
}
else
{
model.IngredientViewModel.Units = units.ToList();
}
model.CategoryName = category.Name;
return View(model);
} }
var categoryEntity = await _categoryRepository.GetCategoryByIdAsync(categoryId); using var transaction = await _context.Database.BeginTransactionAsync();
if (categoryEntity == null) try
{ {
return NotFound($"Kategorie mit ID {categoryId} wurde nicht gefunden."); var categoryEntity = await _categoryRepository.GetCategoryByIdAsync(categoryId);
} if (categoryEntity == null)
{
return NotFound($"Kategorie mit ID {categoryId} wurde nicht gefunden.");
}
await _recipeRepository.CreateRecipeForCategoryAsync(categoryEntity, name, description, difficulty, servings, preparationTime, cookingTime); model.PreparationTime = new TimeSpan(model.PrepHours, model.PrepMinutes, 0);
var recipe = await _recipeRepository.CreateRecipeForCategoryAsync(categoryEntity, name, description, difficulty, servings, preparationTime, cookingTime); model.CookingTime = new TimeSpan(model.CookHours, model.CookMinutes, 0);
if (photo != null)
var recipe = await _recipeRepository.CreateRecipeForCategoryAsync(
categoryEntity,
model.Name,
model.Description ?? string.Empty,
model.Difficulty,
model.Servings,
model.PreparationTime,
model.CookingTime);
if (model.Photo != null)
{
await _mediaFileRepository.UploadRecipeMediaAsync(recipe.Id, model.Photo);
}
if (model.Video != null)
{
await _mediaFileRepository.UploadRecipeMediaAsync(recipe.Id, model.Video);
}
if (model.IngredientViewModel?.Ingredients != null)
{
foreach (var ingredient in model.IngredientViewModel.Ingredients)
{
var ri = ingredient.RecipeIngredients?.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(ingredient.Name) && ri?.Quantity > 0 && ri?.Unit?.Id != null)
{
await _recipeRepository.CreateRecipeIngredientAsync(
recipe.Id,
ingredient.Name,
ri.Quantity,
ri.Unit.Id);
}
}
}
if (model.InstructionViewModel?.Instructions != null)
{
for (var i = 0; i < model.InstructionViewModel.Instructions.Count; i++)
{
var instruction = model.InstructionViewModel.Instructions[i];
if (!string.IsNullOrWhiteSpace(instruction.Description))
{
var fileKey = $"InstructionViewModel.Instructions[{i}].MediaFile";
IFormFile? imageFile = null;
if (Request.Form.Files.Any(f => f.Name == fileKey))
{
imageFile = Request.Form.Files[fileKey];
}
await _instructionRepository.CreateInstructionAsync(
recipe.Id,
instruction.Description,
imageFile);
}
}
}
await transaction.CommitAsync();
return RedirectToAction("Details", new { recipeId = recipe.Id });
}
catch (Exception ex)
{ {
await _mediaFileRepository.UploadRecipeMediaAsync(recipe.Id, photo); await transaction.RollbackAsync();
return BadRequest(ex.Message);
} }
return RedirectToAction("Details", "Category", new { id = categoryId });
} }
// GET: /Recipe/{recipeId}/RemoveIngredient/{ingredientId} // GET: /Recipe/{recipeId}/RemoveIngredient/{ingredientId}
@@ -252,26 +379,40 @@
return NotFound("Recipe not found."); return NotFound("Recipe not found.");
} }
ViewBag.RecipeId = recipeId; var instructions = await _instructionRepository.GetInstructionsOfRecipeAsync(recipeId);
return View(recipe);
var viewModel = new InstructionViewModel
{
RecipeId = recipeId,
Instructions = instructions,
};
return View(viewModel);
} }
// POST: /Recipe/{recipeId}/AddInstruction // POST: /Recipe/{recipeId}/AddInstruction
[HttpPost("{recipeId}/AddInstruction")] [HttpPost("{recipeId}/AddInstruction")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AddInstruction(Guid recipeId, string description) public async Task<IActionResult> AddInstruction(Guid recipeId, string description, IFormFile? image)
{ {
try try
{ {
await _instructionRepository.CreateInstructionToRecipeAsync(recipeId, description); await _instructionRepository.CreateInstructionAsync(recipeId, description, image);
var recipe = await _recipeRepository.GetRecipeByIdAsync(recipeId);
var instructions = recipe?.Instructions?.ToList() ?? new List<Instruction>();
var viewModel = new InstructionViewModel
{
RecipeId = recipeId,
Instructions = instructions,
};
return RedirectToAction("AddInstruction", new { recipeId }); return RedirectToAction("AddInstruction", new { recipeId });
} }
catch (Exception ex) catch (Exception ex)
{ {
ModelState.AddModelError(string.Empty, ex.Message); return BadRequest(ex.Message);
var recipe = await _recipeRepository.GetRecipeByIdAsync(recipeId);
ViewBag.RecipeId = recipeId;
return View(recipe);
} }
} }
@@ -312,5 +453,22 @@
return PartialView("_FavoriteButton", recipe); return PartialView("_FavoriteButton", recipe);
} }
// GET: /Recipe/{recipeId}/GetIngredients
[HttpGet("{recipeId}/GetIngredients")]
public async Task<IActionResult> GetIngredients(Guid recipeId)
{
var recipeIngredients = (await _ingredientRepository.GetIngredientsByRecipeIdAsync(recipeId)).Select(ri => ri.Ingredient).ToList();
var units = await _unitRepository.GetAllUnitsAsync();
var viewModel = new IngredientViewModel
{
RecipeId = recipeId,
Ingredients = recipeIngredients,
Units = units,
};
return PartialView("_IngredientsPartial", viewModel);
}
} }
} }
@@ -1,6 +1,25 @@
namespace Francesco.Recipes.World.Controller.Unit namespace Francesco.Recipes.World.Controller.Unit
{ {
public class UnitController using Francesco.Recipes.World.Repositories.Unit;
using Microsoft.AspNetCore.Mvc;
[Route("Unit")]
public class UnitController : Controller
{ {
private readonly IUnitRepository _unitRepository;
public UnitController(IUnitRepository unitRepository)
{
_unitRepository = unitRepository;
}
[HttpGet("GetAllUnits")]
public async Task<IActionResult> GetAllUnits()
{
var units = (await _unitRepository.GetAllUnitsAsync())
.Select(u => new { id = u.Id, name = u.Name })
.ToList();
return Json(units);
}
} }
} }
@@ -0,0 +1,571 @@
// <auto-generated />
using System;
using Francesco.Recipes.World.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Francesco.Recipes.World.Migrations
{
[DbContext(typeof(FrancescosRecipesWorldDbContext))]
[Migration("20250430094852_MakeInstructionOptionalInMediaFile")]
partial class MakeInstructionOptionalInMediaFile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Category.Category", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Categories");
b.HasData(
new
{
Id = new Guid("b248244f-f21c-4555-a14d-5dd49a2717cf"),
Name = "Vorspeisen & Snacks"
},
new
{
Id = new Guid("5186c5d6-5aff-4a6e-baf4-b61b72d889fe"),
Name = "Erste Gänge"
},
new
{
Id = new Guid("abbc6bb0-97ea-49f5-b31e-6507eb784fd1"),
Name = "Hauptgerichte"
},
new
{
Id = new Guid("90deec39-dcd0-422d-9018-ac8389e332e1"),
Name = "Desserts & Süßspeisen"
},
new
{
Id = new Guid("28e39168-701a-4084-81da-d96c987c462f"),
Name = "Beilagen & Salate"
},
new
{
Id = new Guid("20585b74-4805-4aff-a6df-aa6b7af04ff1"),
Name = "Kuchen"
},
new
{
Id = new Guid("adfb75ce-3ef3-428b-a5ca-b0c4c619d5ec"),
Name = "Hefegebäck & Brot"
},
new
{
Id = new Guid("d332f88d-d241-48c5-a2f2-bfd124eada7e"),
Name = "Soßen & Saucen"
},
new
{
Id = new Guid("23b1c740-e427-44f9-a6ea-d33d3f30f05a"),
Name = "Marmeladen & Eingemachtes"
},
new
{
Id = new Guid("0a91a200-dc76-4e00-b38c-b38cab5b69d7"),
Name = "Getränke"
});
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Favorit.Favorit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("Favorits");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Ingredient.Ingredient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Ingredients");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.IngredientShoppingList.RecipeIngredientShoppingList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("IngredientId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsChecked")
.HasColumnType("bit");
b.Property<Guid>("RecipeIngredientId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RecipeShoppingListId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IngredientId");
b.HasIndex("RecipeIngredientId");
b.HasIndex("RecipeShoppingListId");
b.ToTable("RecipeIngredientsShoppingLists");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<Guid>("RecipeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RecipeId");
b.ToTable("Instructions");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.MediaFile.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<byte[]>("Data")
.HasColumnType("varbinary(max)");
b.Property<string>("FileName")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("InstructionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("MimeType")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("RecipeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("InstructionId");
b.HasIndex("RecipeId");
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CategoryId")
.HasColumnType("uniqueidentifier");
b.Property<TimeSpan>("CookingTime")
.HasColumnType("time");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int>("Difficulty")
.HasColumnType("int");
b.Property<Guid>("FavoritId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsFavorite")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("datetime2");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<TimeSpan>("PreparationTime")
.HasColumnType("time");
b.Property<int>("Servings")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("FavoritId");
b.ToTable("Recipes");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeIngredient.RecipeIngredient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("IngredientId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<Guid>("RecipeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UnitId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IngredientId");
b.HasIndex("RecipeId");
b.HasIndex("UnitId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeShoppingList.RecipeShoppingList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RecipeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ShoppingListId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RecipeId");
b.HasIndex("ShoppingListId");
b.ToTable("RecipeShoppingLists");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("ShoppingLists");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Unit.Unit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Symbol")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Units");
b.HasData(
new
{
Id = new Guid("62eb2c11-8768-46fb-9db0-c77f940bb4aa"),
Name = "liter",
Symbol = "l"
},
new
{
Id = new Guid("6c41f7e6-ca75-49cc-8541-cebd5a9c560b"),
Name = "gramm",
Symbol = "g"
},
new
{
Id = new Guid("7e3d1b86-ac48-45d6-814e-d3492a86db1d"),
Name = "kilogramm",
Symbol = "kg"
},
new
{
Id = new Guid("0c3648ec-4981-42c8-abf8-18bf1a2ff4c2"),
Name = "stücke",
Symbol = "stk"
},
new
{
Id = new Guid("d82f4abd-e4e4-4104-a9c0-1acdeaa701f5"),
Name = "blatt",
Symbol = "blatt"
},
new
{
Id = new Guid("df5cb4c3-4de6-4c6f-be8c-da41b2986408"),
Name = "messerspitze",
Symbol = "msp"
},
new
{
Id = new Guid("e45a3af2-2ed6-4ac4-b06f-b4175663a7be"),
Name = "stange",
Symbol = "stange"
},
new
{
Id = new Guid("7ea2f51d-7493-4f19-a663-1f309186d3ae"),
Name = "bund",
Symbol = "bund"
},
new
{
Id = new Guid("66556e0e-eb2b-4bc3-9a56-135dd508ed09"),
Name = "zehe",
Symbol = "zehe"
},
new
{
Id = new Guid("2e9894ac-14fa-43fd-ba07-35cdd8ebd461"),
Name = "teelöffel",
Symbol = "TL"
},
new
{
Id = new Guid("24a91b89-3389-4465-89e4-2f70d2ea6fd7"),
Name = "esslöffel",
Symbol = "EL"
});
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.IngredientShoppingList.RecipeIngredientShoppingList", b =>
{
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Ingredient.Ingredient", null)
.WithMany("IngredientShoppingLists")
.HasForeignKey("IngredientId");
b.HasOne("Francesco.Recipes.World.Models.BackendModels.RecipeIngredient.RecipeIngredient", "RecipeIngredient")
.WithMany()
.HasForeignKey("RecipeIngredientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.RecipeShoppingList.RecipeShoppingList", "RecipeShoppingList")
.WithMany("SelectedIngredients")
.HasForeignKey("RecipeShoppingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecipeIngredient");
b.Navigation("RecipeShoppingList");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction", b =>
{
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", "Recipe")
.WithMany("Instructions")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Recipe");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.MediaFile.MediaFile", b =>
{
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction", "Instruction")
.WithMany("MediaFiles")
.HasForeignKey("InstructionId");
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", "Recipe")
.WithMany("MediaFiles")
.HasForeignKey("RecipeId");
b.Navigation("Instruction");
b.Navigation("Recipe");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", b =>
{
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Category.Category", "Category")
.WithMany("Recipes")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Favorit.Favorit", "Favorit")
.WithMany("Recipe")
.HasForeignKey("FavoritId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Favorit");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeIngredient.RecipeIngredient", b =>
{
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Ingredient.Ingredient", "Ingredient")
.WithMany("RecipeIngredients")
.HasForeignKey("IngredientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", "Recipe")
.WithMany("RecipeIngredients")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Unit.Unit", "Unit")
.WithMany("RecipeIngredient")
.HasForeignKey("UnitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ingredient");
b.Navigation("Recipe");
b.Navigation("Unit");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeShoppingList.RecipeShoppingList", b =>
{
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", "Recipe")
.WithMany()
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", "ShoppingList")
.WithMany("RecipeShoppingList")
.HasForeignKey("ShoppingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Recipe");
b.Navigation("ShoppingList");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Category.Category", b =>
{
b.Navigation("Recipes");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Favorit.Favorit", b =>
{
b.Navigation("Recipe");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Ingredient.Ingredient", b =>
{
b.Navigation("IngredientShoppingLists");
b.Navigation("RecipeIngredients");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction", b =>
{
b.Navigation("MediaFiles");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", b =>
{
b.Navigation("Instructions");
b.Navigation("MediaFiles");
b.Navigation("RecipeIngredients");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeShoppingList.RecipeShoppingList", b =>
{
b.Navigation("SelectedIngredients");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", b =>
{
b.Navigation("RecipeShoppingList");
});
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Unit.Unit", b =>
{
b.Navigation("RecipeIngredient");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Francesco.Recipes.World.Migrations
{
/// <inheritdoc />
public partial class MakeInstructionOptionalInMediaFile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder is null)
{
throw new ArgumentNullException(nameof(migrationBuilder));
}
migrationBuilder.DropForeignKey(
name: "FK_MediaFiles_Instructions_InstructionId",
table: "MediaFiles");
migrationBuilder.AlterColumn<Guid>(
name: "InstructionId",
table: "MediaFiles",
type: "uniqueidentifier",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uniqueidentifier");
migrationBuilder.AddForeignKey(
name: "FK_MediaFiles_Instructions_InstructionId",
table: "MediaFiles",
column: "InstructionId",
principalTable: "Instructions",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
if (migrationBuilder is null)
{
throw new ArgumentNullException(nameof(migrationBuilder));
}
migrationBuilder.DropForeignKey(
name: "FK_MediaFiles_Instructions_InstructionId",
table: "MediaFiles");
migrationBuilder.AlterColumn<Guid>(
name: "InstructionId",
table: "MediaFiles",
type: "uniqueidentifier",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uniqueidentifier",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_MediaFiles_Instructions_InstructionId",
table: "MediaFiles",
column: "InstructionId",
principalTable: "Instructions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -182,7 +182,7 @@ namespace Francesco.Recipes.World.Migrations
b.Property<string>("FileName") b.Property<string>("FileName")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<Guid>("InstructionId") b.Property<Guid?>("InstructionId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<string>("MimeType") b.Property<string>("MimeType")
@@ -441,9 +441,7 @@ namespace Francesco.Recipes.World.Migrations
{ {
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction", "Instruction") b.HasOne("Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction", "Instruction")
.WithMany("MediaFiles") .WithMany("MediaFiles")
.HasForeignKey("InstructionId") .HasForeignKey("InstructionId");
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", "Recipe") b.HasOne("Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe", "Recipe")
.WithMany("MediaFiles") .WithMany("MediaFiles")
@@ -509,7 +507,7 @@ namespace Francesco.Recipes.World.Migrations
.IsRequired(); .IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", "ShoppingList") b.HasOne("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", "ShoppingList")
.WithMany("RecipeIngredientShoppingLists") .WithMany("RecipeShoppingList")
.HasForeignKey("ShoppingListId") .HasForeignKey("ShoppingListId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -557,7 +555,7 @@ namespace Francesco.Recipes.World.Migrations
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", b => modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Shoppinglist.ShoppingList", b =>
{ {
b.Navigation("RecipeIngredientShoppingLists"); b.Navigation("RecipeShoppingList");
}); });
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Unit.Unit", b => modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.Unit.Unit", b =>
@@ -13,8 +13,8 @@
public byte[]? Data { get; set; } public byte[]? Data { get; set; }
public virtual Recipe? Recipe { get; set; } = new (); public virtual Recipe? Recipe { get; set; } = null;
public virtual Instruction Instruction { get; set; } = new (); public virtual Instruction? Instruction { get; set; } = null;
} }
} }
@@ -0,0 +1,39 @@
using Francesco.Recipes.World.Models.BackendModels.Recipe;
namespace Francesco.Recipes.World.Models
{
public class CreateRecipeViewModel
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public Difficulty Difficulty { get; set; }
public int Servings { get; set; }
public TimeSpan PreparationTime { get; set; }
public TimeSpan CookingTime { get; set; }
public int PrepHours { get; set; }
public int PrepMinutes { get; set; }
public int CookHours { get; set; }
public int CookMinutes { get; set; }
public Guid CategoryId { get; set; }
public string? CategoryName { get; set; }
public IFormFile? Photo { get; set; }
public IFormFile? Video { get; set; }
public IngredientViewModel? IngredientViewModel { get; set; }
public InstructionViewModel? InstructionViewModel { get; set; }
}
}
@@ -0,0 +1,14 @@
namespace Francesco.Recipes.World.Models
{
using Francesco.Recipes.World.Models.BackendModels.Ingredient;
using Francesco.Recipes.World.Models.BackendModels.Unit;
public class IngredientViewModel
{
public Guid RecipeId { get; set; }
public List<Ingredient> Ingredients { get; set; } = new List<Ingredient>();
public List<Unit> Units { get; set; } = new List<Unit>();
}
}
@@ -1,11 +1,13 @@
namespace Francesco.Recipes.World.Models using Francesco.Recipes.World.Models.BackendModels.Instruction;
{
using Francesco.Recipes.World.Models.BackendModels.Instruction;
namespace Francesco.Recipes.World.Models
{
public class InstructionViewModel public class InstructionViewModel
{ {
public Guid RecipeId { get; set; } public Guid RecipeId { get; set; }
public string Description { get; set; } = string.Empty;
public List<Instruction> Instructions { get; set; } = new List<Instruction>(); public List<Instruction> Instructions { get; set; } = new List<Instruction>();
} }
} }
@@ -6,7 +6,7 @@
{ {
Task<Instruction> GetInstructionAsync(Guid instructionId); Task<Instruction> GetInstructionAsync(Guid instructionId);
Task<Instruction> CreateInstructionToRecipeAsync(Guid recipeId, string description); Task<Instruction> CreateInstructionAsync(Guid recipeId, string description, IFormFile? photo);
Task RemoveInstructionFromRecipeAsync(Guid recipeId, Guid instructionId); Task RemoveInstructionFromRecipeAsync(Guid recipeId, Guid instructionId);
@@ -2,6 +2,7 @@
{ {
using Francesco.Recipes.World.Data; using Francesco.Recipes.World.Data;
using Francesco.Recipes.World.Models.BackendModels.Instruction; using Francesco.Recipes.World.Models.BackendModels.Instruction;
using Francesco.Recipes.World.Models.BackendModels.MediaFile;
using Francesco.Recipes.World.Repositories.Recipe; using Francesco.Recipes.World.Repositories.Recipe;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -22,7 +23,7 @@
return instruction ?? throw new InvalidDataException($"Instruction {instructionId} not found."); return instruction ?? throw new InvalidDataException($"Instruction {instructionId} not found.");
} }
public async Task<Instruction> CreateInstructionToRecipeAsync(Guid recipeId, string description) public async Task<Instruction> CreateInstructionAsync(Guid recipeId, string description, IFormFile? photo)
{ {
if (string.IsNullOrWhiteSpace(description)) if (string.IsNullOrWhiteSpace(description))
{ {
@@ -38,8 +39,11 @@
throw new ArgumentException("Recipe not found.", nameof(recipeId)); throw new ArgumentException("Recipe not found.", nameof(recipeId));
} }
var nextNumber = recipe.Instructions?.Max(i => i.Number) ?? 0; var nextNumber = 1;
nextNumber++; if (recipe.Instructions != null && recipe.Instructions.Any())
{
nextNumber = recipe.Instructions.Max(i => i.Number) + 1;
}
var newInstruction = new Instruction var newInstruction = new Instruction
{ {
@@ -52,6 +56,24 @@
_context.Instructions.Add(newInstruction); _context.Instructions.Add(newInstruction);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
if (photo != null && photo.Length > 0)
{
using var memoryStream = new MemoryStream();
await photo.CopyToAsync(memoryStream);
var instructionImage = new MediaFile
{
FileName = photo.FileName,
MimeType = photo.ContentType,
Data = memoryStream.ToArray(),
Instruction = newInstruction,
Recipe = null,
};
_context.Add(instructionImage);
await _context.SaveChangesAsync();
}
return newInstruction; return newInstruction;
} }
@@ -88,6 +110,7 @@
public async Task<List<Instruction>> GetInstructionsOfRecipeAsync(Guid recipeId) public async Task<List<Instruction>> GetInstructionsOfRecipeAsync(Guid recipeId)
{ {
var instructions = await _context.Instructions var instructions = await _context.Instructions
.Include(i => i.MediaFiles)
.Where(i => i.Recipe.Id == recipeId) .Where(i => i.Recipe.Id == recipeId)
.OrderBy(i => i.Number) .OrderBy(i => i.Number)
.ToListAsync(); .ToListAsync();
@@ -64,6 +64,7 @@
MimeType = photo.ContentType, MimeType = photo.ContentType,
Data = memoryStream.ToArray(), Data = memoryStream.ToArray(),
Instruction = instruction, Instruction = instruction,
Recipe = null,
}; };
_context.Add(instructionImage); _context.Add(instructionImage);
@@ -73,44 +74,52 @@
public async Task UploadRecipeMediaAsync(Guid recipeId, IFormFile mediaFile) public async Task UploadRecipeMediaAsync(Guid recipeId, IFormFile mediaFile)
{ {
if (mediaFile == null) if (mediaFile == null || mediaFile.Length == 0)
{ {
throw new ArgumentNullException(nameof(mediaFile)); return;
} }
var recipe = await _recipeRepository.GetRecipeAsync(recipeId); try
var isImage = mediaFile.ContentType.StartsWith("image/");
var isVideo = mediaFile.ContentType.StartsWith("video/");
if (!isImage && !isVideo)
{ {
throw new InvalidOperationException("Only image or video files are allowed."); var recipe = await _recipeRepository.GetRecipeAsync(recipeId);
var isImage = mediaFile.ContentType.StartsWith("image/");
var isVideo = mediaFile.ContentType.StartsWith("video/");
if (!isImage && !isVideo)
{
throw new InvalidOperationException("Only image or video files are allowed.");
}
if (isImage)
{
await RemoveExistingMediaAsync(recipe, "image/");
}
else
{
await RemoveExistingMediaAsync(recipe, "video/");
}
using var memoryStream = new MemoryStream();
await mediaFile.CopyToAsync(memoryStream);
var newMedia = new MediaFile
{
Id = Guid.NewGuid(),
FileName = mediaFile.FileName,
MimeType = mediaFile.ContentType,
Data = memoryStream.ToArray(),
Recipe = recipe,
Instruction = null,
};
_context.MediaFiles.Add(newMedia);
await _context.SaveChangesAsync();
} }
catch (Exception ex)
if (isImage)
{ {
await RemoveExistingMediaAsync(recipe, "image/"); throw new InvalidOperationException("An error occurred while uploading the media file.", ex);
} }
else
{
await RemoveExistingMediaAsync(recipe, "video/");
}
using var memoryStream = new MemoryStream();
await mediaFile.CopyToAsync(memoryStream);
var newMedia = new MediaFile
{
Id = Guid.NewGuid(),
FileName = mediaFile.FileName,
MimeType = mediaFile.ContentType,
Data = memoryStream.ToArray(),
Recipe = recipe,
};
_context.MediaFiles.Add(newMedia);
await _context.SaveChangesAsync();
} }
private async Task RemoveExistingMediaAsync(Recipe recipe, string mediaTypePrefix) private async Task RemoveExistingMediaAsync(Recipe recipe, string mediaTypePrefix)
@@ -9,7 +9,7 @@
Task<Recipe?> GetRecipeByIdAsync(Guid id); Task<Recipe?> GetRecipeByIdAsync(Guid id);
Task AddOrCreateIngredientToRecipeAsync(Guid recipeId, string ingredientName, int quantity, Guid unitId); Task CreateRecipeIngredientAsync(Guid recipeId, string ingredientName, int quantity, Guid unitId);
Task RemoveIngredientFromRecipeAsync(Guid recipeId, Guid ingredientId); Task RemoveIngredientFromRecipeAsync(Guid recipeId, Guid ingredientId);
@@ -41,7 +41,7 @@
.FirstOrDefaultAsync(r => r.Id == recipeId); .FirstOrDefaultAsync(r => r.Id == recipeId);
} }
public async Task AddOrCreateIngredientToRecipeAsync(Guid recipeId, string ingredientName, int quantity, Guid unitId) public async Task CreateRecipeIngredientAsync(Guid recipeId, string ingredientName, int quantity, Guid unitId)
{ {
var recipe = await GetRecipeAsync(recipeId); var recipe = await GetRecipeAsync(recipeId);
var unit = await _unitRepository.GetUnitByIdAsync(unitId); var unit = await _unitRepository.GetUnitByIdAsync(unitId);
@@ -8,6 +8,6 @@
Task<Unit> AddUnitAsync(string name, string symbol); Task<Unit> AddUnitAsync(string name, string symbol);
Task<IEnumerable<Unit>> GetAllUnitsAsync(); Task<List<Unit>> GetAllUnitsAsync();
} }
} }
@@ -35,7 +35,7 @@
return unit; return unit;
} }
public async Task<IEnumerable<Unit>> GetAllUnitsAsync() public async Task<List<Unit>> GetAllUnitsAsync()
{ {
return await _context.Units.ToListAsync(); return await _context.Units.ToListAsync();
} }
@@ -71,11 +71,8 @@
<div class="col-md-3 mb-4"> <div class="col-md-3 mb-4">
<div class="card h-100 text-center"> <div class="card h-100 text-center">
<div class="card-body d-flex flex-column justify-content-center"> <div class="card-body d-flex flex-column justify-content-center">
<a href="/Recipe/Create?categoryId=@category.Category.Id" <a href="/Create/@category.Category.Id"
class="btn btn-outline-primary" class="btn btn-outline-primary">Rezept hinzufügen</a>
hx-get="/Recipe/Create?categoryId=@category.Category.Id"
hx-target="#category-@category.Category.Id"
hx-swap="beforeend">Rezept hinzufügen</a>
</div> </div>
</div> </div>
</div> </div>
@@ -1,27 +0,0 @@
@{
ViewData["Title"] = "Add or Create Ingredient to Recipe";
}
<h2>@ViewData["Title"]</h2>
<form asp-action="AddOrCreateIngredient" method="post">
<input type="hidden" id="RecipeId" name="recipeId" value="@ViewBag.RecipeId" />
<div class="form-group">
<label for="IngredientName" class="control-label">Ingredient Name</label>
<input type="text" id="IngredientName" name="ingredientName" class="form-control" />
</div>
<div class="form-group">
<label for="Quantity" class="control-label">Quantity</label>
<input type="number" id="Quantity" name="quantity" class="form-control" />
</div>
<div class="form-group">
<label for="UnitId" class="control-label">Unit</label>
<select id="UnitId" name="unitId" class="form-control" asp-items="ViewBag.Units"></select>
</div>
<button type="submit" class="btn btn-primary">Add Ingredient</button>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -1,58 +1,114 @@
@model Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe @model Francesco.Recipes.World.Models.CreateRecipeViewModel
@using Francesco.Recipes.World.Models.BackendModels.Recipe @using Francesco.Recipes.World.Models.BackendModels.Recipe
@using Francesco.Recipes.World.Models
@{ @{
ViewData["Title"] = "Create Recipe"; ViewData["Title"] = "Erstelle Rezept";
} }
<h1>Create Recipe</h1> <h1 class="mb-4">Erstelle Rezept</h1>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" asp-controller="Recipe" asp-action="Create" asp-route-categoryId="@Model.CategoryId">
<div class="form-group"> <div class="mb-4">
<label asp-for="Name"></label> <h2>Allgemein</h2>
<input asp-for="Name" class="form-control" /> <div class="form-group">
<span asp-validation-for="Name" class="text-danger"></span> <label asp-for="Name">Name</label>
<input asp-for="Name" class="form-control" required />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Description">Beschreibung</label>
<textarea asp-for="Description" class="form-control"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Difficulty">Schwierigkeit</label>
<select asp-for="Difficulty" class="form-control" required>
<option value="">Schwierigkeit wählen</option>
@foreach (var difficulty in Enum.GetValues(typeof(Difficulty)))
{
<option value="@difficulty">@difficulty</option>
}
</select>
<span asp-validation-for="Difficulty" class="text-danger"></span>
</div>
<div class="form-row">
<div class="col-sm-4">
<label asp-for="Servings">Portion (pro Person)</label>
<input asp-for="Servings" type="number" min="1" class="form-control" required />
<span asp-validation-for="Servings" class="text-danger"></span>
</div>
<div class="col-sm-4">
<label>Vorbereitungszeit</label>
<div class="d-flex">
<div class="input-group mr-2">
<input asp-for="PrepHours" type="number" class="form-control" min="0" value="0" />
<div class="input-group-append">
<span class="input-group-text">h</span>
</div>
</div>
<div class="input-group">
<input asp-for="PrepMinutes" type="number" class="form-control" min="0" max="59" value="0" />
<div class="input-group-append">
<span class="input-group-text">min</span>
</div>
</div>
</div>
</div>
<div class="col-sm-4">
<label>Kochzeit</label>
<div class="d-flex">
<div class="input-group mr-2">
<input asp-for="CookHours" type="number" class="form-control" min="0" value="0" />
<div class="input-group-append">
<span class="input-group-text">h</span>
</div>
</div>
<div class="input-group">
<input asp-for="CookMinutes" type="number" class="form-control" min="0" max="59" value="0" />
<div class="input-group-append">
<span class="input-group-text">min</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="form-group">
<label asp-for="Description"></label> <div class="mb-4">
<textarea asp-for="Description" class="form-control"></textarea> <h2>Zutaten</h2>
<span asp-validation-for="Description" class="text-danger"></span> @await Html.PartialAsync("_IngredientsPartial", Model.IngredientViewModel ?? new IngredientViewModel
{
RecipeId = Model.CategoryId,
Ingredients = new List<Francesco.Recipes.World.Models.BackendModels.Ingredient.Ingredient>(),
Units = ViewBag.Units ?? new List<Francesco.Recipes.World.Models.BackendModels.Unit.Unit>()
})
</div> </div>
<div class="form-group">
<label asp-for="Difficulty"></label> <div class="mb-4">
<select asp-for="Difficulty" class="form-control"> <h2>Anweisungen</h2>
<option value="">Select Difficulty</option> @await Html.PartialAsync("_GetInstructions", Model.InstructionViewModel ?? new InstructionViewModel
@foreach (var difficulty in Enum.GetValues(typeof(Difficulty))) {
{ RecipeId = Model.CategoryId,
<option value="@difficulty">@difficulty</option> Instructions = new List<Francesco.Recipes.World.Models.BackendModels.Instruction.Instruction>()
} })
</select>
<span asp-validation-for="Difficulty" class="text-danger"></span>
</div> </div>
<div class="form-group">
<label asp-for="Servings"></label> <div class="mb-4">
<input asp-for="Servings" class="form-control" /> <h2>Bild/Video</h2>
<span asp-validation-for="Servings" class="text-danger"></span> <div class="form-group">
<label asp-for="Photo">Foto</label>
<input asp-for="Photo" class="form-control-file" accept="image/*" />
</div>
<div class="form-group">
<label asp-for="Video">Video</label>
<input asp-for="Video" class="form-control-file" accept="video/*" />
</div>
</div> </div>
<div class="form-group">
<label asp-for="PreparationTime"></label> <div class="text-center mt-4">
<input asp-for="PreparationTime" class="form-control" /> <button type="submit" class="btn btn-success btn-lg">Rezept speichern</button>
<span asp-validation-for="PreparationTime" class="text-danger"></span> <a asp-action="Index" asp-controller="Category" class="btn btn-secondary btn-lg ml-2">Abbrechen</a>
</div> </div>
<div class="form-group">
<label asp-for="CookingTime"></label>
<input asp-for="CookingTime" class="form-control" />
<span asp-validation-for="CookingTime" class="text-danger"></span>
</div>
<div class="form-group">
<label for="photo">Photo</label>
<input type="file" name="photo" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form> </form>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
}
@@ -18,6 +18,3 @@
</div> </div>
</form> </form>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}
@@ -5,114 +5,34 @@
{ {
<div class="instruction-item" id="instruction-@Model.Instructions[i].Id"> <div class="instruction-item" id="instruction-@Model.Instructions[i].Id">
<div class="instruction-controls"> <div class="instruction-controls">
<input type="file" /> @if (Model.Instructions[i].MediaFiles != null && Model.Instructions[i].MediaFiles.Any())
<textarea placeholder="Beschreibung" class="form-control">@Model.Instructions[i].Description</textarea> {
<button type="button" class="btn-delete" onclick="removeInstruction('@Model.Instructions[i].Id')">🗑️</button> var mediaFile = Model.Instructions[i].MediaFiles.First();
if (mediaFile.Data != null)
{
<div class="instruction-media">
<img src="data:@mediaFile.MimeType;base64,@Convert.ToBase64String(mediaFile.Data)"
alt="Instruction Media" class="instruction-media-preview" />
</div>
}
}
<input type="file" name="InstructionViewModel.Instructions[@i].MediaFile" class="instruction-file" />
<textarea name="InstructionViewModel.Instructions[@i].Description" placeholder="Beschreibung" class="form-control">@Model.Instructions[i].Description</textarea>
<button type="button" class="btn-delete" onclick="removeInstruction('@Model.Instructions[i].Id', '@Model.RecipeId',)">🗑️</button>
</div> </div>
<div class="instruction-actions"> <div class="instruction-actions">
<button type="button" class="btn-move-up" onclick="moveInstructionUp('@Model.Instructions[i].Id')">⬆️</button> <button type="button" class="btn-move-up" onclick="moveInstructionUp('@Model.Instructions[i].Id', '@Model.RecipeId')">⬆️</button>
<button type="button" class="btn-move-down" onclick="moveInstructionDown('@Model.Instructions[i].Id')">⬇️</button> <button type="button" class="btn-move-down" onclick="moveInstructionDown('@Model.Instructions[i].Id', '@Model.RecipeId')">⬇️</button>
</div> </div>
</div> </div>
} }
</div> </div>
<button type="button" class="btn-add" onclick="addInstruction()">Schritte hinzufügen</button> <button type="button" class="btn-add" onclick="addInstruction()">Schritte hinzufügen</button>
@section Scripts {
<script>
htmx.on('htmx:afterSwap', (event) => {
if (event.target.id === 'instructions-container') {
console.log('Instructions reloaded.');
}
});
const recipeId = '@Model.RecipeId';
async function moveInstructionUp(instructionId) {
try {
const response = await fetch(`/Recipe/${recipeId}/Instruction/${instructionId}/move-up`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
htmx.ajax('GET', `/Recipe/${recipeId}/Instructions`, '#instructions-container');
} else {
const error = await response.json();
alert(error.Error || 'Failed to move instruction up.');
}
} catch (error) {
console.error('Error moving instruction up:', error);
}
}
async function moveInstructionDown(instructionId) {
try {
const response = await fetch(`/Recipe/${recipeId}/Instruction/${instructionId}/move-down`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
htmx.ajax('GET', `/Recipe/${recipeId}/Instructions`, '#instructions-container');
} else {
const error = await response.json();
alert(error.Error || 'Failed to move instruction down.');
}
} catch (error) {
console.error('Error moving instruction down:', error);
}
}
async function removeInstruction(instructionId) {
if (!confirm('Möchtest du diesen Schritt wirklich löschen?')) return;
try {
const response = await fetch(`/${recipeId}/RemoveInstruction/${instructionId}`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
const element = document.getElementById(`instruction-${instructionId}`);
if (element) {
element.remove();
}
} else {
const error = await response.json();
alert(error.Error || 'Fehler beim Löschen der Anweisung.');
}
} catch (error) {
console.error('Fehler beim Löschen:', error);
}
}
function addInstruction() {
const container = document.getElementById('instructions-container');
const newInstructionHtml = `
<div class="instruction-item">
<div class="instruction-controls">
<input type="file" />
<textarea placeholder="Beschreibung" class="form-control"></textarea>
<button type="button" class="btn-delete" onclick="deleteInstruction()">🗑️</button>
</div>
<div class="instruction-actions">
<button type="button" class="btn-move-up" onclick="moveInstructionUp()">⬆️</button>
<button type="button" class="btn-move-down" onclick="moveInstructionDown()">⬇️</button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', newInstructionHtml);
}
</script>
}
@@ -0,0 +1,31 @@
@model Francesco.Recipes.World.Models.IngredientViewModel
@Html.AntiForgeryToken()
<div id="ingredients-container">
@for (int i = 0; i < Model.Ingredients.Count; i++)
{
<div class="ingredient-item" id="ingredient-@Model.Ingredients[i].Id">
<div class="ingredient-controls">
<input type="text" name="IngredientViewModel.Ingredients[@i].Name" value="@Model.Ingredients[i].Name" placeholder="Name" class="form-control" />
<input type="number" name="IngredientViewModel.Ingredients[@i].RecipeIngredients[0].Quantity" value="@(Model.Ingredients[i].RecipeIngredients.FirstOrDefault()?.Quantity)" placeholder="Menge" class="form-control" />
<select name="IngredientViewModel.Ingredients[@i].RecipeIngredients[0].Unit.Id" class="form-control">
@foreach (var unit in Model.Units)
{
@if (Model.Ingredients[i].RecipeIngredients.FirstOrDefault()?.Unit?.Id == unit.Id)
{
<option value="@unit.Id" selected>@unit.Name (@unit.Symbol)</option>
}
else
{
<option value="@unit.Id">@unit.Name (@unit.Symbol)</option>
}
}
</select>
<button type="button" class="btn-delete" onclick="removeIngredient('@Model.Ingredients[i].Id')">🗑️</button>
</div>
</div>
}
</div>
<button type="button" class="btn-add" onclick="addIngredient()">Zutat hinzufügen</button>
@@ -63,7 +63,18 @@
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js" integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1" crossorigin="anonymous"></script> <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js" integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1" crossorigin="anonymous"></script>
<script> <script>
// Light/Dark mode toggle logic
let recipeId;
@if (ViewData["RecipeId"] != null)
{
<text>recipeId = '@ViewData["RecipeId"]';</text>
}
else if (ViewData["CategoryId"] != null)
{
<text>recipeId = '@ViewData["CategoryId"]';</text>
}
const darkModeToggle = document.getElementById('darkModeToggle'); const darkModeToggle = document.getElementById('darkModeToggle');
const lightModeToggle = document.getElementById('lightModeToggle'); const lightModeToggle = document.getElementById('lightModeToggle');
darkModeToggle.addEventListener('click', () => { darkModeToggle.addEventListener('click', () => {
@@ -72,8 +83,8 @@
lightModeToggle.addEventListener('click', () => { lightModeToggle.addEventListener('click', () => {
document.body.classList.remove('bg-dark', 'text-white'); document.body.classList.remove('bg-dark', 'text-white');
}); });
</script>
<script>
document.body.addEventListener('htmx:configRequest', (event) => { document.body.addEventListener('htmx:configRequest', (event) => {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value; const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
if (token) { if (token) {
@@ -81,7 +92,7 @@
} }
}); });
</script> </script>
@await Html.PartialAsync("_ValidationScriptsPartial")
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>
</html> </html>
@@ -19,4 +19,69 @@ html {
body { body {
margin-bottom: 60px; margin-bottom: 60px;
}
.instruction-item, .ingredient-item {
background-color: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 4px;
}
.instruction-controls, .ingredient-controls {
display: flex;
align-items: center;
gap: 10px;
}
.instruction-media {
width: 80px;
height: 80px;
overflow: hidden;
margin-right: 10px;
}
.instruction-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.instruction-file {
max-width: 200px;
}
textarea.form-control {
min-height: 80px;
}
.btn-delete, .btn-move-up, .btn-move-down, .btn-save {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
}
.btn-delete {
color: #dc3545;
}
.btn-save {
color: #28a745;
}
.instruction-actions {
display: flex;
justify-content: flex-end;
margin-top: 5px;
}
.btn-add {
background-color: #007bff;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
} }
+190 -3
View File
@@ -1,4 +1,191 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification htmx.on('htmx:afterSwap', (event) => {
// for details on configuring this project to bundle and minify static web assets. if (event.target.id === 'instructions-container') {
console.log('Instructions reloaded.');
}
if (event.target.id === 'ingredients-container') {
console.log('Ingredients reloaded.');
}
});
let recipeId;
function setRecipeId(id) {
recipeId = id;
}
async function moveInstructionUp(instructionId, recipeIdParam) {
const idToUse = recipeIdParam || recipeId;
if (!idToUse) {
alert('Recipe ID is not set. Please select a recipe first.');
return;
}
try {
const response = await fetch(`/Recipe/${idToUse}/Instruction/${instructionId}/move-up`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
htmx.ajax('GET', `/Recipe/${idToUse}/Instructions`, '#instructions-container');
} else {
const error = await response.json();
alert(error.Error || 'Failed to move instruction up.');
}
} catch (error) {
console.error('Error moving instruction up:', error);
}
}
async function moveInstructionDown(instructionId, recipeIdParam) {
const idToUse = recipeIdParam || recipeId;
if (!idToUse) {
alert('Recipe ID is not set. Please select a recipe first.');
return;
}
try {
const response = await fetch(`/Recipe/${idToUse}/Instruction/${instructionId}/move-down`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
htmx.ajax('GET', `/Recipe/${idToUse}/Instructions`, '#instructions-container');
} else {
const error = await response.json();
alert(error.Error || 'Failed to move instruction down.');
}
} catch (error) {
console.error('Error moving instruction down:', error);
}
}
async function removeInstruction(instructionId, recipeIdParam) {
const idToUse = recipeIdParam || recipeId;
if (!idToUse) {
alert('Recipe ID is not set. Please select a recipe first.');
return;
}
if (!confirm('Möchtest du diesen Schritt wirklich löschen?')) return;
try {
const response = await fetch(`/${idToUse}/RemoveInstruction/${instructionId}`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
const element = document.getElementById(`instruction-${instructionId}`);
if (element) {
element.remove();
}
} else {
const error = await response.json();
alert(error.Error || 'Fehler beim Löschen der Anweisung.');
}
} catch (error) {
console.error('Fehler beim Löschen:', error);
}
}
function addInstruction() {
const container = document.getElementById('instructions-container');
const index = document.querySelectorAll('.instruction-item').length;
const newInstructionHtml = `
<div class="instruction-item" id="instruction-new-${index}">
<div class="instruction-controls">
<input type="file" name="InstructionViewModel.Instructions[${index}].MediaFile" class="instruction-file" />
<textarea name="InstructionViewModel.Instructions[${index}].Description" placeholder="Beschreibung" class="form-control"></textarea>
<button type="button" class="btn-delete" onclick="document.getElementById('instruction-new-${index}').remove()">🗑️</button>
</div>
<div class="instruction-actions">
<button type="button" class="btn-move-up" disabled>⬆️</button>
<button type="button" class="btn-move-down" disabled>⬇️</button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', newInstructionHtml);
}
async function removeIngredient(ingredientId) {
if (!confirm('Möchtest du diese Zutat wirklich löschen?')) return;
try {
const response = await fetch(`/${recipeId}/RemoveIngredient/${ingredientId}`, {
method: 'POST',
headers: {
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
}
});
if (response.ok) {
const element = document.getElementById(`ingredient-${ingredientId}`);
if (element) {
element.remove();
}
} else {
const error = await response.json();
alert(error.Error || 'Fehler beim Löschen der Zutat.');
}
} catch (error) {
console.error('Fehler beim Löschen:', error);
}
}
async function addIngredient() {
const container = document.getElementById('ingredients-container');
const index = document.querySelectorAll('.ingredient-item').length;
const newIngredientHtml = `
<div class="ingredient-item" id="ingredient-new-${index}">
<div class="ingredient-controls">
<input type="text" name="IngredientViewModel.Ingredients[${index}].Name" placeholder="Name" class="form-control" />
<input type="number" name="IngredientViewModel.Ingredients[${index}].RecipeIngredients[0].Quantity" placeholder="Menge" class="form-control" />
<select name="IngredientViewModel.Ingredients[${index}].RecipeIngredients[0].Unit.Id" class="form-control unit-select">
<option value="">Lade Einheiten...</option>
</select>
<button type="button" class="btn-delete" onclick="document.getElementById('ingredient-new-${index}').remove()">🗑️</button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', newIngredientHtml);
const addedElement = document.getElementById(`ingredient-new-${index}`);
const unitSelect = addedElement.querySelector('.unit-select');
try {
const response = await fetch('/Unit/GetAllUnits');
if (response.ok) {
const units = await response.json();
console.log('Fetched units:', units);
unitSelect.innerHTML = '';
units.forEach(unit => {
const option = new Option(unit.name, unit.id);
unitSelect.add(option);
});
} else {
console.error('Failed to fetch units');
unitSelect.innerHTML = '<option value="">Fehler beim Laden</option>';
}
} catch (error) {
console.error('Error fetching units:', error);
unitSelect.innerHTML = '<option value="">Fehler beim Laden</option>';
}
}
// Write your JavaScript code.