Merge branch 'feature/Favorite-Site' into 'develop'

Favorite-Site

See merge request francesco.damico/francescos.recipes.world!17
This commit is contained in:
Francesco D'Amico
2025-06-03 12:34:12 +00:00
19 changed files with 1034 additions and 145 deletions
@@ -0,0 +1,7 @@
namespace Francesco.Recipes.World.Constants
{
public class ContentType
{
public const string Image = "image/";
}
}
@@ -0,0 +1,8 @@
namespace Francesco.Recipes.World.Constants
{
public class SortOrders
{
public const string Newest = "newest";
public const string Oldest = "oldest";
}
}
@@ -0,0 +1,35 @@
namespace Francesco.Recipes.World.Controller.Favorite
{
using Francesco.Recipes.World.Constants;
using Francesco.Recipes.World.Models;
using Francesco.Recipes.World.Repositories.Favorit;
using Microsoft.AspNetCore.Mvc;
[Route("Favorite")]
public class FavoriteController : Controller
{
private readonly IFavoriteRepository _favoriteRepository;
public FavoriteController(IFavoriteRepository favoriteRepository)
{
_favoriteRepository = favoriteRepository;
}
public async Task<IActionResult> Index(string sortOrder = SortOrders.Newest)
{
var favoriteRecipes = await _favoriteRepository.GetFavoriteRecipesAsync();
var sortedRecipes = sortOrder == SortOrders.Oldest
? favoriteRecipes.OrderBy(r => r.Favorite.CreatedAt)
: favoriteRecipes.OrderByDescending(r => r.Favorite.CreatedAt);
var viewModel = new FavoriteViewModel
{
FavoriteRecipes = sortedRecipes,
SortOrder = sortOrder,
};
return View(viewModel);
}
}
}
@@ -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("20250602133408_RenameFavoriteColumn")]
partial class RenameFavoriteColumn
{
/// <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>("FavoriteId")
.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("FavoriteId");
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", "Favorite")
.WithMany("Recipe")
.HasForeignKey("FavoriteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Favorite");
});
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,72 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Francesco.Recipes.World.Migrations
{
/// <inheritdoc />
public partial class RenameFavoriteColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder is null)
{
throw new ArgumentNullException(nameof(migrationBuilder));
}
migrationBuilder.DropForeignKey(
name: "FK_Recipes_Favorits_FavoritId",
table: "Recipes");
migrationBuilder.RenameColumn(
name: "FavoritId",
table: "Recipes",
newName: "FavoriteId");
migrationBuilder.RenameIndex(
name: "IX_Recipes_FavoritId",
table: "Recipes",
newName: "IX_Recipes_FavoriteId");
migrationBuilder.AddForeignKey(
name: "FK_Recipes_Favorits_FavoriteId",
table: "Recipes",
column: "FavoriteId",
principalTable: "Favorits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
if (migrationBuilder is null)
{
throw new ArgumentNullException(nameof(migrationBuilder));
}
migrationBuilder.DropForeignKey(
name: "FK_Recipes_Favorits_FavoriteId",
table: "Recipes");
migrationBuilder.RenameColumn(
name: "FavoriteId",
table: "Recipes",
newName: "FavoritId");
migrationBuilder.RenameIndex(
name: "IX_Recipes_FavoriteId",
table: "Recipes",
newName: "IX_Recipes_FavoritId");
migrationBuilder.AddForeignKey(
name: "FK_Recipes_Favorits_FavoritId",
table: "Recipes",
column: "FavoritId",
principalTable: "Favorits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -221,7 +221,7 @@ namespace Francesco.Recipes.World.Migrations
b.Property<int>("Difficulty") b.Property<int>("Difficulty")
.HasColumnType("int"); .HasColumnType("int");
b.Property<Guid>("FavoritId") b.Property<Guid>("FavoriteId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<bool>("IsFavorite") b.Property<bool>("IsFavorite")
@@ -244,7 +244,7 @@ namespace Francesco.Recipes.World.Migrations
b.HasIndex("CategoryId"); b.HasIndex("CategoryId");
b.HasIndex("FavoritId"); b.HasIndex("FavoriteId");
b.ToTable("Recipes"); b.ToTable("Recipes");
}); });
@@ -460,15 +460,15 @@ namespace Francesco.Recipes.World.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Francesco.Recipes.World.Models.BackendModels.Favorit.Favorit", "Favorit") b.HasOne("Francesco.Recipes.World.Models.BackendModels.Favorit.Favorit", "Favorite")
.WithMany("Recipe") .WithMany("Recipe")
.HasForeignKey("FavoritId") .HasForeignKey("FavoriteId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Category"); b.Navigation("Category");
b.Navigation("Favorit"); b.Navigation("Favorite");
}); });
modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeIngredient.RecipeIngredient", b => modelBuilder.Entity("Francesco.Recipes.World.Models.BackendModels.RecipeIngredient.RecipeIngredient", b =>
@@ -34,7 +34,7 @@ public class Recipe : ITimeStampedEntity
public virtual ICollection<MediaFile> MediaFiles { get; set; } = new List<MediaFile>(); public virtual ICollection<MediaFile> MediaFiles { get; set; } = new List<MediaFile>();
public virtual Favorit Favorit { get; set; } = new (); public virtual Favorit Favorite { get; set; } = new ();
public virtual Category Category { get; set; } = new (); public virtual Category Category { get; set; } = new ();
} }
@@ -0,0 +1,16 @@
using Francesco.Recipes.World.Constants;
using Francesco.Recipes.World.Models.BackendModels.Recipe;
namespace Francesco.Recipes.World.Models
{
public class FavoriteViewModel
{
public IEnumerable<Recipe> FavoriteRecipes { get; set; } = new List<Recipe>();
public string SortOrder { get; set; } = SortOrders.Newest;
public bool HasFavorites => FavoriteRecipes.Any();
public string SortOrderDisplayText => SortOrder == SortOrders.Oldest ? "Älteste Favorits" : "Neueste Favorits";
}
}
@@ -0,0 +1,21 @@
namespace Francesco.Recipes.World.Models
{
public class SearchViewModel
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsFavorite { get; set; }
public byte[]? ImageData { get; set; }
public string? MimeType { get; set; }
public List<string> Ingredients { get; set; } = new ();
public TimeSpan TotalTime { get; set; }
}
}
+2 -1
View File
@@ -20,7 +20,8 @@ var connectionString = builder.Configuration.GetConnectionString("FrancescosReci
?? throw new InvalidOperationException("Connection string 'FrancescosRecipesWorldDbContextConnection' not found."); ?? throw new InvalidOperationException("Connection string 'FrancescosRecipesWorldDbContextConnection' not found.");
services.AddDbContext<FrancescosRecipesWorldDbContext>(options => services.AddDbContext<FrancescosRecipesWorldDbContext>(options =>
options.UseSqlServer(connectionString)); options.UseSqlServer(connectionString, sqlOptions =>
sqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));
// Add services to the container. // Add services to the container.
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
@@ -47,6 +47,7 @@
return await _context.Categories return await _context.Categories
.Include(c => c.Recipes) .Include(c => c.Recipes)
.ThenInclude(r => r.MediaFiles) .ThenInclude(r => r.MediaFiles)
.AsSplitQuery()
.ToListAsync(); .ToListAsync();
} }
} }
@@ -17,6 +17,8 @@
{ {
return await _context.Recipes return await _context.Recipes
.Where(r => r.IsFavorite) .Where(r => r.IsFavorite)
.Include(r => r.Favorite)
.Include(r => r.MediaFiles)
.ToListAsync(); .ToListAsync();
} }
@@ -28,12 +30,32 @@
public async Task AddFavoriteAsync(Guid recipeId) public async Task AddFavoriteAsync(Guid recipeId)
{ {
var recipe = await _context.Recipes.FindAsync(recipeId); var recipe = await _context.Recipes
if (recipe != null && !recipe.IsFavorite) .Include(r => r.Favorite)
.FirstOrDefaultAsync(r => r.Id == recipeId);
if (recipe == null)
{ {
recipe.IsFavorite = true; throw new InvalidOperationException("Recipe not found.");
await _context.SaveChangesAsync();
} }
if (recipe.IsFavorite)
{
throw new InvalidOperationException("Recipe is already a favorite.");
}
recipe.IsFavorite = true;
if (recipe.Favorite == null || recipe.Favorite.Id == Guid.Empty)
{
recipe.Favorite = new Models.BackendModels.Favorit.Favorit
{
Id = Guid.NewGuid(),
CreatedAt = DateTime.Now,
};
}
await _context.SaveChangesAsync();
} }
public async Task RemoveFavoriteAsync(Guid recipeId) public async Task RemoveFavoriteAsync(Guid recipeId)
@@ -1,5 +1,6 @@
namespace Francesco.Recipes.World.Repositories.MediaFile namespace Francesco.Recipes.World.Repositories.MediaFile
{ {
using Francesco.Recipes.World.Constants;
using Francesco.Recipes.World.Data; using Francesco.Recipes.World.Data;
using Francesco.Recipes.World.Models.BackendModels.MediaFile; using Francesco.Recipes.World.Models.BackendModels.MediaFile;
using Francesco.Recipes.World.Models.BackendModels.Recipe; using Francesco.Recipes.World.Models.BackendModels.Recipe;
@@ -83,7 +84,7 @@
{ {
var recipe = await _recipeRepository.GetRecipeAsync(recipeId); var recipe = await _recipeRepository.GetRecipeAsync(recipeId);
var isImage = mediaFile.ContentType.StartsWith("image/"); var isImage = mediaFile.ContentType.StartsWith(ContentType.Image);
var isVideo = mediaFile.ContentType.StartsWith("video/"); var isVideo = mediaFile.ContentType.StartsWith("video/");
if (!isImage && !isVideo) if (!isImage && !isVideo)
@@ -93,7 +94,7 @@
if (isImage) if (isImage)
{ {
await RemoveExistingMediaAsync(recipe, "image/"); await RemoveExistingMediaAsync(recipe, ContentType.Image);
} }
else else
{ {
@@ -1,5 +1,6 @@
namespace Francesco.Recipes.World.Repositories.Recipe namespace Francesco.Recipes.World.Repositories.Recipe
{ {
using Francesco.Recipes.World.Models;
using Francesco.Recipes.World.Models.BackendModels.Category; using Francesco.Recipes.World.Models.BackendModels.Category;
using Francesco.Recipes.World.Models.BackendModels.Recipe; using Francesco.Recipes.World.Models.BackendModels.Recipe;
@@ -17,8 +18,7 @@
Task<Recipe> CreateRecipeForCategoryAsync(Category category, string name, string description, Difficulty difficulty, int servings, TimeSpan preparationTime, TimeSpan cookingTime); Task<Recipe> CreateRecipeForCategoryAsync(Category category, string name, string description, Difficulty difficulty, int servings, TimeSpan preparationTime, TimeSpan cookingTime);
Task<IEnumerable<Recipe>> SearchInRecipesAndIngredients(string searchterm);
Task<bool> DeleteRecipeAsync(Guid recipeId); Task<bool> DeleteRecipeAsync(Guid recipeId);
Task<IEnumerable<SearchViewModel>> SearchInRecipesAndIngredients(string searchTerm);
} }
} }
@@ -1,6 +1,9 @@
namespace Francesco.Recipes.World.Repositories.Recipe namespace Francesco.Recipes.World.Repositories.Recipe
{ {
using System.Linq.Expressions;
using Francesco.Recipes.World.Constants;
using Francesco.Recipes.World.Data; using Francesco.Recipes.World.Data;
using Francesco.Recipes.World.Models;
using Francesco.Recipes.World.Models.BackendModels.Category; using Francesco.Recipes.World.Models.BackendModels.Category;
using Francesco.Recipes.World.Models.BackendModels.Ingredient; using Francesco.Recipes.World.Models.BackendModels.Ingredient;
using Francesco.Recipes.World.Models.BackendModels.Recipe; using Francesco.Recipes.World.Models.BackendModels.Recipe;
@@ -148,21 +151,32 @@
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
public async Task<IEnumerable<Recipe>> SearchInRecipesAndIngredients(string searchTerm) public async Task<IEnumerable<SearchViewModel>> SearchInRecipesAndIngredients(string searchTerm)
{ {
var queryable = _context.Recipes try
.Include(r => r.RecipeIngredients)
.ThenInclude(ri => ri.Ingredient)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{ {
searchTerm = searchTerm.ToLower(); if (string.IsNullOrWhiteSpace(searchTerm))
queryable = queryable.Where(r => r.Name.ToLower().Contains(searchTerm) || {
r.RecipeIngredients.Any(ri => ri.Ingredient.Name.ToLower().Contains(searchTerm))); return await _context.Recipes
.OrderByDescending(r => r.CreatedAt)
.Take(20)
.Select(SearchViewModelSelector())
.ToListAsync();
} }
return await queryable.ToListAsync(); var normalizedSearchTerm = searchTerm.ToLower();
return await ApplyRecipeSearchFilter(_context.Recipes, normalizedSearchTerm)
.OrderByDescending(r => r.CreatedAt)
.Take(100)
.Select(SearchViewModelSelector())
.ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Error in SearchInRecipesAndIngredientsOptimized: {ex.Message}");
return new List<SearchViewModel>();
}
} }
public async Task<IReadOnlyCollection<Recipe>> GetRecipesByDifficultyAsync(Difficulty? difficulty) public async Task<IReadOnlyCollection<Recipe>> GetRecipesByDifficultyAsync(Difficulty? difficulty)
@@ -184,6 +198,35 @@
.ToListAsync(); .ToListAsync();
} }
private static Expression<Func<Recipe, SearchViewModel>> SearchViewModelSelector()
{
return r => new SearchViewModel
{
Id = r.Id,
Name = r.Name,
Description = r.Description,
IsFavorite = r.IsFavorite,
ImageData = r.MediaFiles
.Where(m => m.MimeType != null && m.MimeType.StartsWith(ContentType.Image))
.Select(m => m.Data)
.FirstOrDefault(),
MimeType = r.MediaFiles
.Where(m => m.MimeType != null && m.MimeType.StartsWith(ContentType.Image))
.Select(m => m.MimeType)
.FirstOrDefault(),
Ingredients = r.RecipeIngredients.Select(ri => ri.Ingredient.Name).ToList(),
TotalTime = r.PreparationTime.Add(r.CookingTime),
};
}
private static IQueryable<Recipe> ApplyRecipeSearchFilter(IQueryable<Recipe> query, string normalizedSearchTerm)
{
return query.Where(r =>
EF.Functions.Like(r.Name.ToLower(), $"%{normalizedSearchTerm}%") ||
(r.Description != null && EF.Functions.Like(r.Description.ToLower(), $"%{normalizedSearchTerm}%")) ||
r.RecipeIngredients.Any(ri => EF.Functions.Like(ri.Ingredient.Name.ToLower(), $"%{normalizedSearchTerm}%")));
}
public async Task<bool> DeleteRecipeAsync(Guid recipeId) public async Task<bool> DeleteRecipeAsync(Guid recipeId)
{ {
var recipe = await GetRecipeByIdAsync(recipeId); var recipe = await GetRecipeByIdAsync(recipeId);
@@ -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>
}
+10 -14
View File
@@ -1,26 +1,26 @@
@model IEnumerable<Francesco.Recipes.World.Views.Category.CategoryRecipesViewModel> @model IEnumerable<Francesco.Recipes.World.Views.Category.CategoryRecipesViewModel>
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<div class="welcome-banner text-center mb-4"> <div class="welcome-banner text-center mb-4">
<img src="/images/banner2.jpg" alt="Willkommen" class="img-fluid" /> <img src="/images/banner2.jpg" alt="Willkommen" class="img-fluid" />
<h1 class="mt-3">Willkommen in der Rezept-App</h1> <h1 class="mt-3">Willkommen in der Rezept-App</h1>
</div> </div>
<form hx-get="/Home/Search" <form hx-get="/Home/Search"
hx-target="#search-results" hx-target="#search-results"
hx-trigger="keyup changed delay:300ms" hx-trigger="keyup changed delay:300ms"
onsubmit="return false;" onsubmit="return false;"
class="mb-4"> class="mb-4">
<input type="text" name="query" class="form-control" placeholder="Suchen nach Rezepten oder Zutaten..." autocomplete="off" /> <input type="text" name="term" class="form-control" placeholder="Suchen nach Rezepten oder Zutaten..." autocomplete="off" />
</form> </form>
<div id="search-results" class="mt-4"></div> <div id="search-results" class="mt-4"></div>
@foreach (var category in Model) @foreach (var category in Model)
{ {
<div class="category-section mb-5" id="category-@category.Category.Id"> <div class="category-section mb-5" id="category-@category.Category.Id">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2>@category.Category.Name</h2> <h2>@category.Category.Name</h2>
@@ -39,7 +39,6 @@
var mimeType = mediaFile?.MimeType; var mimeType = mediaFile?.MimeType;
<div class="col-md-3 mb-4"> <div class="col-md-3 mb-4">
<a href="/Recipe/Details/@recipe.Id" class="text-decoration-none">
<div class="card h-100"> <div class="card h-100">
<div class="recipe-image"> <div class="recipe-image">
@if (imageData != null && mimeType != null) @if (imageData != null && mimeType != null)
@@ -64,7 +63,6 @@
class="btn btn-primary mt-auto">Details</a> class="btn btn-primary mt-auto">Details</a>
</div> </div>
</div> </div>
</a>
</div> </div>
} }
@@ -78,6 +76,4 @@
</div> </div>
</div> </div>
</div> </div>
} }
@@ -1,24 +1,21 @@
@model IEnumerable<Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe> @model IEnumerable<Francesco.Recipes.World.Models.SearchViewModel>
@{ @{
if (!Model.Any()) if (!Model.Any())
{ {
<p class="text-muted">Keine Ergebnisse gefunden.</p> <p class="text-muted">Keine Ergebnisse gefunden.</p>
} }
else else
{ {
<div class="row"> <div class="row">
@foreach (var recipe in Model) @foreach (var recipe in Model)
{ {
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<div class="recipe-image"> <div class="recipe-image">
@{ @if (recipe.ImageData != null && recipe.MimeType != null)
var mediaFile = recipe.MediaFiles.FirstOrDefault();
if (mediaFile?.Data != null)
{ {
<img src="data:@mediaFile.MimeType;base64,@Convert.ToBase64String(mediaFile.Data)" <img src="data:@recipe.MimeType;base64,@Convert.ToBase64String(recipe.ImageData)"
alt="@recipe.Name" alt="@recipe.Name"
class="card-img-top" /> class="card-img-top" />
} }
@@ -28,7 +25,6 @@ else
alt="Platzhalter" alt="Platzhalter"
class="card-img-top" /> class="card-img-top" />
} }
}
</div> </div>
<div class="card-body d-flex flex-column justify-content-between"> <div class="card-body d-flex flex-column justify-content-between">
@@ -36,23 +32,44 @@ else
<h5 class="card-title">@recipe.Name</h5> <h5 class="card-title">@recipe.Name</h5>
<p class="card-text text-muted mb-2"> <p class="card-text text-muted mb-2">
<i class="bi bi-clock"></i> <i class="bi bi-clock"></i>
@recipe.PreparationTime.Hours h @recipe.PreparationTime.Minutes min @recipe.TotalTime.Hours h @recipe.TotalTime.Minutes min
</p> </p>
</div> </div>
<div> <div class="d-flex justify-content-between align-items-center mt-3">
<button class="btn btn-outline-secondary" <div class="favorite-button-container">
hx-post="/Recipe/ToggleFavorite" <form hx-post="@Url.Action(recipe.IsFavorite ? "RemoveFavorite" : "AddFavorite", "Recipe")"
hx-vals='{"id": "@recipe.Id"}' hx-target="this"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="bi @((recipe.IsFavorite) ? "bi-star-fill" : "bi-star")"></i> @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> </button>
</form>
</div>
<a href="/Recipe/Details/@recipe.Id" class="btn btn-primary btn-sm">Details</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} }
</div> </div>
}
} }
}
@@ -1,11 +1,11 @@
@model Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe @model Francesco.Recipes.World.Models.BackendModels.Recipe.Recipe
<button class="btn btn-link p-0" <form hx-post="@Url.Action(Model.IsFavorite ? "RemoveFavorite" : "AddFavorite", "Recipe")"
style="width: 40px; height: 40px;"
hx-post="@Url.Action(Model.IsFavorite ? "RemoveFavorite" : "AddFavorite", "Recipe")"
hx-vals='{"recipeId": "@Model.Id"}'
hx-target="this" hx-target="this"
hx-swap="outerHTML"> hx-swap="outerHTML">
@Html.AntiForgeryToken()
<input type="hidden" name="recipeId" value="@Model.Id" />
<button type="submit" class="btn btn-link p-0" style="width: 40px; height: 40px;">
@if (Model.IsFavorite) @if (Model.IsFavorite)
{ {
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFD700"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFD700">
@@ -22,4 +22,5 @@
2 9.24l5.46 4.73L5.82 21z" /> 2 9.24l5.46 4.73L5.82 21z" />
</svg> </svg>
} }
</button> </button>
</form>