Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace CulinaryCommand.Migrations
{
/// <inheritdoc />
public partial class AddRecipeImageAndMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Allergens",
table: "Recipes",
type: "varchar(256)",
maxLength: 256,
nullable: true);

migrationBuilder.AddColumn<string>(
name: "ImageData",
table: "Recipes",
type: "longtext",
nullable: true);

migrationBuilder.AddColumn<string>(
name: "PortionSize",
table: "Recipes",
type: "varchar(128)",
maxLength: 128,
nullable: true);

}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Allergens",
table: "Recipes");

migrationBuilder.DropColumn(
name: "ImageData",
table: "Recipes");

migrationBuilder.DropColumn(
name: "PortionSize",
table: "Recipes");

}
}
}
11 changes: 11 additions & 0 deletions CulinaryCommandApp/Migrations/AppDbContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)

MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("RecipeId"));

b.Property<string>("Allergens")
.HasMaxLength(256)
.HasColumnType("varchar(256)");

b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
Expand All @@ -911,12 +915,19 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("CreatedAt")
.HasColumnType("datetime(6)");

b.Property<string>("ImageData")
.HasColumnType("longtext");

b.Property<bool>("IsSubRecipe")
.HasColumnType("bit(1)");

b.Property<int>("LocationId")
.HasColumnType("int");

b.Property<string>("PortionSize")
.HasMaxLength(128)
.HasColumnType("varchar(128)");

b.Property<string>("RecipeType")
.IsRequired()
.HasMaxLength(128)
Expand Down
12 changes: 11 additions & 1 deletion CulinaryCommandApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@

options.TokenValidationParameters.NameClaimType = "cognito:username";
options.TokenValidationParameters.RoleClaimType = "cognito:groups";

// Allow correlation/nonce cookies over plain HTTP in development
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;

options.Events.OnRedirectToIdentityProvider = ctx =>
{
// Forces correct scheme/host behind nginx
Expand All @@ -119,7 +124,12 @@
// =====================
// AI Services
// =====================
builder.Services.AddSingleton<Client>(_ => new Client());
builder.Services.AddSingleton<Client>(sp =>
{
var apiKey = builder.Configuration["Google:ApiKey"]
?? throw new InvalidOperationException("Google:ApiKey is not configured.");
return new Client(apiKey: apiKey);
});
builder.Services.AddScoped<AIReportingService>();

//
Expand Down
9 changes: 9 additions & 0 deletions CulinaryCommandApp/Recipe/Entities/Recipe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ public class Recipe

public bool IsSubRecipe { get; set; } = false;

[MaxLength(128)]
public string? PortionSize { get; set; }

[MaxLength(256)]
public string? Allergens { get; set; }

// Base64-encoded image (no MaxLength → LONGTEXT in MySQL)
public string? ImageData { get; set; }

public DateTime? CreatedAt { get; set; }

// Optimistic concurrency token — backed by a MySQL timestamp(6) column
Expand Down
13 changes: 5 additions & 8 deletions CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,11 @@ else if (_notFound)
else
{
<div class="container py-4" style="max-width: 860px;">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="/recipes">Recipes</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Edit — @_model!.Title</li>
</ol>
</nav>
<div class="re-back-row">
<a href="/recipes/view/@Id" class="re-back-btn">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>

<RecipeForm Model="_model!"
FormTitle="Edit Recipe"
Expand Down
27 changes: 27 additions & 0 deletions CulinaryCommandApp/Recipe/Pages/RecipeEdit.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.re-back-row {
display: flex;
align-items: center;
margin-bottom: 16px;
}

.re-back-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 16px;
border-radius: 10px;
font-weight: 600;
font-size: 0.88rem;
text-decoration: none;
background: #ffffff;
color: #1f2a37;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 4px rgba(31, 42, 55, 0.06);
transition: all 0.18s ease;
}

.re-back-btn:hover {
background: #f3f4f6;
border-color: #d1d5db;
color: #1f2a37;
}
101 changes: 101 additions & 0 deletions CulinaryCommandApp/Recipe/Pages/RecipeForm.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@using CulinaryCommandApp.Inventory.Entities
@using CulinaryCommandApp.Inventory.Services
@using CulinaryCommandApp.Inventory.Services.Interfaces
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.Threading
@inject IIngredientService IngredientService
Expand Down Expand Up @@ -162,6 +163,64 @@ else {

<hr />

@* ---- Section: Recipe Image ---- *@
<h6 class="text-uppercase text-muted fw-semibold mb-3">Recipe Image</h6>

<div class="mb-4">
<div class="rf-image-card">
@if (!string.IsNullOrWhiteSpace(Model.ImageData))
{
<div class="rf-image-preview">
<img src="@Model.ImageData" alt="Recipe preview" class="rf-preview-img" />
<button type="button" class="rf-remove-img" @onclick="RemoveImage" title="Remove image">
<i class="bi bi-x-lg"></i>
</button>
</div>
}
else
{
<div class="rf-image-placeholder">
<i class="bi bi-image"></i>
<p>No image uploaded</p>
<p class="rf-upload-hint">JPG, PNG or WEBP · max 5 MB</p>
</div>
}
</div>
<InputFile id="recipeImageInput"
class="form-control mt-2"
accept="image/*"
OnChange="HandleImageUpload" />
@if (!string.IsNullOrWhiteSpace(_imageError))
{
<div class="text-danger small mt-1">@_imageError</div>
}
</div>

<hr />

@* ---- Section: Serving Info ---- *@
<h6 class="text-uppercase text-muted fw-semibold mb-3">Serving &amp; Nutrition</h6>

<div class="row mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Portion Size</label>
<input class="form-control"
placeholder="e.g. 6 oz serving"
@bind="Model.PortionSize" />
</div>
</div>

@* Allergens *@
<div class="mb-4">
<label class="form-label fw-semibold">Allergens</label>
<input class="form-control"
placeholder="e.g. Fish, Gluten, Nuts"
@bind="Model.Allergens" />
<div class="form-text">Comma-separate multiple allergens.</div>
</div>

<hr />

@* ---- Section 2: Ingredient Lines ----*@
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="text-uppercase text-muted fw-semibold mb-0">Ingredients</h6>
Expand Down Expand Up @@ -401,6 +460,9 @@ else {
private bool _dataLoading;
private readonly SemaphoreSlim _loadSemaphore = new(1, 1);

// Image upload
private string? _imageError;

// Reference data
private List<string> _recipeTypes = new();
private List<string> _recipeCategories = new();
Expand Down Expand Up @@ -766,6 +828,45 @@ else {

private void Cancel() => Nav.NavigateTo("/recipes");

// *** Image upload ***

private async Task HandleImageUpload(InputFileChangeEventArgs e)
{
_imageError = null;
var file = e.File;

const long maxBytes = 5 * 1024 * 1024; // 5 MB
if (file.Size > maxBytes)
{
_imageError = "Image must be under 5 MB.";
return;
}

if (!file.ContentType.StartsWith("image/"))
{
_imageError = "Please select a valid image file.";
return;
}

try
{
using var ms = new System.IO.MemoryStream();
await using var stream = file.OpenReadStream(maxBytes);
await stream.CopyToAsync(ms);
var base64 = Convert.ToBase64String(ms.ToArray());
Model.ImageData = $"data:{file.ContentType};base64,{base64}";
}
catch
{
_imageError = "Failed to read the image. Please try again.";
}
}

private void RemoveImage()
{
Model.ImageData = null;
}

// *** View model for ingredient lines ***

private sealed class IngredientLineViewModel {
Expand Down
Loading
Loading