diff --git a/Seasoned.Backend/Controllers/RecipeController.cs b/Seasoned.Backend/Controllers/RecipeController.cs index 77db6b5..1b396dc 100644 --- a/Seasoned.Backend/Controllers/RecipeController.cs +++ b/Seasoned.Backend/Controllers/RecipeController.cs @@ -35,7 +35,6 @@ public class RecipeController : ControllerBase return Ok(result); } - [Authorize] [HttpPost("save")] public async Task SaveRecipe([FromBody] RecipeResponseDto recipeDto) { @@ -49,7 +48,7 @@ public class RecipeController : ControllerBase var recipe = new Recipe { Title = recipeDto.Title, - Icon = recipeDto.Icon, + ImageUrl = recipeDto.ImageUrl, Ingredients = recipeDto.Ingredients, Instructions = recipeDto.Instructions, CreatedAt = DateTime.UtcNow, @@ -62,7 +61,6 @@ public class RecipeController : ControllerBase return Ok(new { message = "Recipe saved to your collection!" }); } - [Authorize] [HttpPut("update/{id}")] public async Task UpdateRecipe(int id, [FromBody] Recipe updatedRecipe) { @@ -79,15 +77,19 @@ public class RecipeController : ControllerBase existingRecipe.Title = updatedRecipe.Title; existingRecipe.Ingredients = updatedRecipe.Ingredients; existingRecipe.Instructions = updatedRecipe.Instructions; + + if (!string.IsNullOrEmpty(updatedRecipe.ImageUrl)) + { + existingRecipe.ImageUrl = updatedRecipe.ImageUrl; + } await _context.SaveChangesAsync(); return Ok(new { message = "Recipe updated successfully!" }); } - [Authorize] [HttpGet("my-collection")] - public async Task>> GetMyRecipes() + public async Task>> GetMyRecipes() { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/Seasoned.Backend/DTOs/RecipeResponseDto.cs b/Seasoned.Backend/DTOs/RecipeResponseDto.cs index aa10a42..effaa38 100644 --- a/Seasoned.Backend/DTOs/RecipeResponseDto.cs +++ b/Seasoned.Backend/DTOs/RecipeResponseDto.cs @@ -3,7 +3,7 @@ namespace Seasoned.Backend.DTOs; public class RecipeResponseDto { public string Title { get; set; } = string.Empty; - public string Icon { get; set; } = "mdi-silverware-fork-knife"; + public string? ImageUrl { get; set; } public List Ingredients { get; set; } = new(); public List Instructions { get; set; } = new(); diff --git a/Seasoned.Backend/Migrations/20260311160009_AddRecipeFields.Designer.cs b/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.Designer.cs similarity index 98% rename from Seasoned.Backend/Migrations/20260311160009_AddRecipeFields.Designer.cs rename to Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.Designer.cs index f75098d..45b2344 100644 --- a/Seasoned.Backend/Migrations/20260311160009_AddRecipeFields.Designer.cs +++ b/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.Designer.cs @@ -13,8 +13,8 @@ using Seasoned.Backend.Data; namespace Seasoned.Backend.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260311160009_AddRecipeFields")] - partial class AddRecipeFields + [Migration("20260318044626_ChangeIconToImageUrl")] + partial class ChangeIconToImageUrl { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -234,8 +234,7 @@ namespace Seasoned.Backend.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Icon") - .IsRequired() + b.Property("ImageUrl") .HasColumnType("text"); b.PrimitiveCollection>("Ingredients") diff --git a/Seasoned.Backend/Migrations/20260311160009_AddRecipeFields.cs b/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.cs similarity index 98% rename from Seasoned.Backend/Migrations/20260311160009_AddRecipeFields.cs rename to Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.cs index 234d7ac..bcd08d2 100644 --- a/Seasoned.Backend/Migrations/20260311160009_AddRecipeFields.cs +++ b/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.cs @@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Seasoned.Backend.Migrations { /// - public partial class AddRecipeFields : Migration + public partial class ChangeIconToImageUrl : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -168,7 +168,7 @@ namespace Seasoned.Backend.Migrations Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), Title = table.Column(type: "text", nullable: false), - Icon = table.Column(type: "text", nullable: false), + ImageUrl = table.Column(type: "text", nullable: true), Ingredients = table.Column>(type: "text[]", nullable: false), Instructions = table.Column>(type: "text[]", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), diff --git a/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs b/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs index 8d2f33b..82aa59e 100644 --- a/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs @@ -231,8 +231,7 @@ namespace Seasoned.Backend.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Icon") - .IsRequired() + b.Property("ImageUrl") .HasColumnType("text"); b.PrimitiveCollection>("Ingredients") diff --git a/Seasoned.Backend/Models/Recipe.cs b/Seasoned.Backend/Models/Recipe.cs index ec0eeff..17a4b8c 100644 --- a/Seasoned.Backend/Models/Recipe.cs +++ b/Seasoned.Backend/Models/Recipe.cs @@ -3,7 +3,7 @@ namespace Seasoned.Backend.Models; public class Recipe { public int Id { get; set; } public string Title { get; set; } = string.Empty; - public string Icon { get; set; } = "mdi-silverware-fork-knife"; + public string? ImageUrl { get; set; } public List Ingredients { get; set; } = new(); public List Instructions { get; set; } = new(); public DateTime CreatedAt { get; set; } diff --git a/Seasoned.Backend/Services/RecipeService.cs b/Seasoned.Backend/Services/RecipeService.cs index 75f648b..0802866 100644 --- a/Seasoned.Backend/Services/RecipeService.cs +++ b/Seasoned.Backend/Services/RecipeService.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; namespace Seasoned.Backend.Services; @@ -18,6 +19,11 @@ public class RecipeService : IRecipeService public async Task ParseRecipeImageAsync(IFormFile image) { + if (image == null || image.Length == 0) + { + return new RecipeResponseDto { Title = "Error: No image provided" }; + } + var googleAI = new GoogleAI(_apiKey); var model = googleAI.GenerativeModel("gemini-3.1-flash-lite-preview"); @@ -27,21 +33,15 @@ public class RecipeService : IRecipeService var base64Image = Convert.ToBase64String(ms.ToArray()); var prompt = @"Extract the recipe details from this image. - - RULES FOR THE 'icon' FIELD: - 1. Select a valid 'Material Design Icon' name (e.g., 'mdi-pasta', 'mdi-bread-slice', 'mdi-muffin', 'mdi-pizza'). - 2. If the recipe type is ambiguous or you cannot find a specific matching icon, you MUST return 'mdi-silverware-fork-knife'. - - IMPORTANT: Return ONLY a raw JSON string. + Return ONLY a raw JSON string. DO NOT include markdown formatting. JSON structure: { - ""title"": ""string"", - ""icon"": ""string"", - ""ingredients"": [""string"", ""string""], - ""instructions"": [""string"", ""string""] + ""title"": ""string"", + ""ingredients"": [""string"", ""string""], + ""instructions"": [""string"", ""string""] }"; - + var generationConfig = new GenerationConfig { ResponseMimeType = "application/json", Temperature = 0.1f @@ -54,24 +54,21 @@ public class RecipeService : IRecipeService var response = await model.GenerateContent(request); string rawText = response.Text?.Trim() ?? ""; - int start = rawText.IndexOf('{'); - int end = rawText.LastIndexOf('}'); + string cleanJson = CleanJsonResponse(rawText); - if (start == -1 || end == -1) + if (string.IsNullOrEmpty(cleanJson)) { - return new RecipeResponseDto { Title = "Error" }; + return new RecipeResponseDto { Title = "Error: Invalid AI Response" }; } - string cleanJson = rawText.Substring(start, (end - start) + 1); - try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var result = JsonSerializer.Deserialize(cleanJson, options); - if (result != null && string.IsNullOrEmpty(result.Icon)) + if (result != null) { - result.Icon = "mdi-silverware-fork-knife"; + result.ImageUrl = $"data:{image.ContentType};base64,{base64Image}"; } return result ?? new RecipeResponseDto { Title = "Empty Response" }; @@ -79,8 +76,6 @@ public class RecipeService : IRecipeService catch (JsonException ex) { Console.WriteLine($"Raw AI Output: {rawText}"); - Console.WriteLine($"Failed to parse JSON: {ex.Message}"); - return new RecipeResponseDto { Title = "Parsing Error" }; } } @@ -108,7 +103,6 @@ public class RecipeService : IRecipeService ""reply"": ""A friendly, thematic response from the Chef."", ""recipe"": { ""title"": ""string"", - ""icon"": ""string (must be a valid mdi- icon name)"", ""ingredients"": [""string"", ""string""], ""instructions"": [""string"", ""string""] } @@ -125,11 +119,15 @@ public class RecipeService : IRecipeService var request = new GenerateContentRequest(fullPrompt, generationConfig); var response = await model.GenerateContent(request); + string rawText = response.Text ?? ""; + string jsonToParse = CleanJsonResponse(rawText); + + if (string.IsNullOrEmpty(jsonToParse)) + return new ChefConsultResponseDto { Reply = "The Chef is at a loss for words. Try rephrasing?" }; + try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - string jsonToParse = response.Text ?? "{ \"reply\": \"The chef is speechless. Try again?\" }"; - var result = JsonSerializer.Deserialize(jsonToParse, options); return result ?? new ChefConsultResponseDto { Reply = "Chef is a bit confused!" }; @@ -139,4 +137,12 @@ public class RecipeService : IRecipeService return new ChefConsultResponseDto { Reply = "The kitchen is a mess right now. Try again?" }; } } + + internal string CleanJsonResponse(string rawText) + { + int start = rawText.IndexOf('{'); + int end = rawText.LastIndexOf('}'); + if (start == -1 || end == -1) return string.Empty; + return rawText.Substring(start, (end - start) + 1); + } } \ No newline at end of file diff --git a/Seasoned.Frontend/app/assets/css/app-theme.css b/Seasoned.Frontend/app/assets/css/app-theme.css index a6af37a..4e197de 100644 --- a/Seasoned.Frontend/app/assets/css/app-theme.css +++ b/Seasoned.Frontend/app/assets/css/app-theme.css @@ -190,6 +190,17 @@ html, body { border-radius: 4px; } +.save-success-btn { + opacity: 1 !important; + color: white !important; + cursor: default; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.transition-swing { + transition: all 0.3s ease; +} + .print-btn { background-color: #3b4e1e !important; color: #f4e4bc !important; @@ -208,89 +219,82 @@ html, body { @media print { @page { - margin: 0.5in 0.75in !important; + margin: 0 !important; + size: auto; } - .v-application, - .v-application__wrap, - main.v-main, - .v-container { - padding-top: 0 !important; - margin-top: 0 !important; - position: static !important; - } - - .recipe-content { - margin-top: -20px !important; - padding-top: 0 !important; - } - - .chat-container, - .v-app-bar, - .no-print, - .separator, - .v-divider, - button, - .v-btn { - display: none !important; - } - - body, .recipe-bg, .landing-page, .recipe-card { + body { background: white !important; - background-image: none !important; - box-shadow: none !important; - color: black !important; + padding: 0.75in !important; + margin: 0 !important; + width: 100% !important; + height: auto !important; + min-height: 0 !important; + box-sizing: border-box !important; + overflow: visible !important; + position: relative !important; + } + + .chat-container, .v-app-bar, .no-print, .separator, .v-divider, .recipe-description, button, .v-btn { + display: none !important; + height: 0 !important; margin: 0 !important; padding: 0 !important; } - .v-row, - .v-col, - .v-container, - .v-card, - .recipe-card, - div[class*="v-col"] { - border: none !important; - outline: none !important; + .v-application, .v-application__wrap, main.v-main, .v-container, .recipe-card { + height: auto !important; + min-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + position: static !important; + } + + html, body { + height: auto !important; + overflow: visible !important; + } + + .v-application { + display: block !important; /* Prevents flex-grow from creating a 2nd page */ + } + + /* 3. Tighten the Footer (Just in case) */ + footer, .brand-subtitle { + page-break-after: avoid !important; + } + + .v-card, + .recipe-card, + .v-application, + .v-application__wrap, + [class*="elevation-"] { box-shadow: none !important; + border: none !important; + background-color: transparent !important; + background-image: none !important; } - - /* 2. Specific fix for Vuetify's "thin" borders */ - .v-border-thin, - .border, - .border-sm { - border-width: 0 !important; - } - - .v-border-thin, - .border, - .border-sm { - border-width: 0 !important; - } - + header.text-center { - margin-top: 0 !important; - padding-top: 0 !important; - margin-bottom: 2px !important; - } - - header.text-center img, - .v-img, - [class*="v-img"] { - max-height: 60px !important; - margin-bottom: 2px !important; - } - - .brand-subtitle { margin-bottom: 5px !important; - font-size: 0.7rem !important; + } + + .recipe-content { + margin: 0 !important; + padding: 0 !important; + } + + header.text-center img, .v-img, [class*="v-img"] { + max-height: 65px !important; + margin-bottom: 2px !important; } .recipe-title { - margin-top: 0 !important; - padding-top: 0 !important; - margin-bottom: 45px !important; + margin-bottom: 40px !important; + font-size: 1.6rem !important; text-align: center; + font-weight: bold !important; + line-height: 1.2 !important; } .recipe-content .v-row { @@ -298,53 +302,41 @@ html, body { flex-direction: row !important; flex-wrap: nowrap !important; width: 100% !important; - gap: 0.4in !important; + gap: 0.5in !important; align-items: flex-start !important; - margin-top: 0 !important; - padding-top: 0 !important; } .recipe-content .v-row > div:first-child { - flex: 0 0 35% !important; - width: 35% !important; - max-width: 35% !important; - padding: 0 !important; + flex: 0 0 33% !important; + max-width: 33% !important; } .recipe-content .v-row > div:last-child { - flex: 0 0 60% !important; - width: 60% !important; - max-width: 60% !important; - padding: 0 !important; - margin: 0 !important; + flex: 0 0 62% !important; + max-width: 62% !important; } .section-header { - margin-bottom: 12px !important; - padding-bottom: 0 !important; - border-bottom: none !important; - width: 100% !important; - } - - * { border: none !important; - box-shadow: none !important; - outline: none !important; + font-weight: bold !important; + margin-bottom: 15px !important; + text-transform: uppercase; } .ingredient-item, .step-text { font-size: 0.95rem !important; - line-height: 1.3 !important; - color: black !important; - } - - .step-number { - border: none !important; + line-height: 1.4 !important; } .instruction-step { - margin-bottom: 8px !important; - gap: 8px !important; + margin-bottom: 10px !important; + display: flex !important; + gap: 10px !important; + } + + .recipe-content, .v-row, .v-col, * { + overflow: visible !important; + height: auto !important; } .step-number, .ingredient-item::before { @@ -352,12 +344,6 @@ html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } - - .recipe-content, .v-row, .v-col { - overflow: visible !important; - height: auto !important; - border: none !important; - } } .section-header { diff --git a/Seasoned.Frontend/app/assets/css/gallery.css b/Seasoned.Frontend/app/assets/css/gallery.css index d20e705..638931e 100644 --- a/Seasoned.Frontend/app/assets/css/gallery.css +++ b/Seasoned.Frontend/app/assets/css/gallery.css @@ -94,10 +94,11 @@ text-align: center !important; } -.v-textarea .v-field__input { - font-weight: 500 !important; - color: #2c2925 !important; +.v-textarea .v-field__input, .v-textarea textarea { + font-family: 'Libre Baskerville', serif !important; + font-size: 1.1rem !important; line-height: 1.6 !important; + color: #2c2925 !important } .v-field-label { @@ -105,6 +106,13 @@ opacity: 0.6 !important; } +.v-textarea .v-label, +.v-textarea .v-field-label { + font-family: 'Libre Baskerville', serif !important; + font-style: italic; +} + + .v-field__outline { --v-field-border-opacity: 1 !important; color: #d1c7b7 !important; diff --git a/Seasoned.Frontend/app/components/RecipeDisplay.vue b/Seasoned.Frontend/app/components/RecipeDisplay.vue index 9d1ebe1..fa95cad 100644 --- a/Seasoned.Frontend/app/components/RecipeDisplay.vue +++ b/Seasoned.Frontend/app/components/RecipeDisplay.vue @@ -4,11 +4,17 @@

{{ recipe.title }}

-

- {{ recipe.description }} -

+ + - +
@@ -45,26 +51,25 @@ - - Save to Collection + + + - - - Saved to Archives -
@@ -75,7 +80,7 @@ import { ref } from 'vue' import '@/assets/css/app-theme.css' defineProps({ - //recipe: { type: Object, default: null }, + recipe: { type: Object, default: null }, isSaving: { type: Boolean, default: false }, hasSaved: { type: Boolean, default: false } }) @@ -87,7 +92,7 @@ const printRecipe = () => { } // mock output -const recipe = ref({ +/*const recipe = ref({ title: "Bakery-Style Lemon Blueberry Muffins", ingredients: [ "2 cups all-purpose flour", @@ -109,5 +114,5 @@ const recipe = ref({ "Toss the blueberries in a teaspoon of flour, then gently fold them into the batter.", "Divide the batter evenly into the muffin cups and bake for 18-20 minutes until golden." ] -}) +}) */ \ No newline at end of file diff --git a/Seasoned.Frontend/app/pages/chat.vue b/Seasoned.Frontend/app/pages/chat.vue index 5720984..5171e38 100644 --- a/Seasoned.Frontend/app/pages/chat.vue +++ b/Seasoned.Frontend/app/pages/chat.vue @@ -18,6 +18,7 @@
+ Ask the Chef @@ -82,9 +83,39 @@ const userQuery = ref('') const chatLoading = ref(false) const chatMessages = ref([]) const chatDisplay = ref(null) +const router = ref(false) const saving = ref(false) const hasSaved = ref(false) +const isAuthenticated = async () => { + try { + await $fetch('/api/auth/manage/info', { credentials: 'include' }) + return true + } catch { + return false + } +} + +const saveToCollection = async () => { + if (!recipe.value || hasSaved.value) return + + saving.value = true + + try { + await $fetch(`${config.public.apiBase}api/recipe/save`, { + method: 'POST', + credentials: 'include', + body: recipe.value + }) + + hasSaved.value = true + } catch (error) { + console.error("Save failed:", error) + } finally { + saving.value = false + } +} + const askChef = async () => { if (!userQuery.value.trim()) return diff --git a/Seasoned.Frontend/app/pages/gallery.vue b/Seasoned.Frontend/app/pages/gallery.vue index 81632bd..8e7fadd 100644 --- a/Seasoned.Frontend/app/pages/gallery.vue +++ b/Seasoned.Frontend/app/pages/gallery.vue @@ -30,8 +30,15 @@ class="rounded-sm mb-4 d-flex align-center justify-center" style="border: 1px solid #e8e2d6;" > + @@ -77,6 +84,7 @@
-

+

Ingredients

@@ -127,7 +135,7 @@ -

+

Instructions

@@ -178,6 +186,45 @@

+ + + + + + +
+ + Update Photo +
+
+
+ + +
+
@@ -214,6 +261,17 @@ const fetchRecipes = async () => { } } +const handleImageUpload = (event) => { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + selectedRecipe.value.imageUrl = e.target.result; + }; + reader.readAsDataURL(file); + +}; const openRecipe = (recipe) => { selectedRecipe.value = { ...recipe } @@ -252,25 +310,31 @@ const closeDetails = () => { const saveChanges = async () => { try { - const payload = { ...selectedRecipe.value }; - if (typeof payload.ingredients === 'string') { - payload.ingredients = payload.ingredients.split('\n').filter(i => i.trim()); - } - if (typeof payload.instructions === 'string') { - payload.instructions = payload.instructions.split('\n').filter(i => i.trim()); - } + const payload = { + ...selectedRecipe.value, + ingredients: typeof selectedRecipe.value.ingredients === 'string' + ? selectedRecipe.value.ingredients.split('\n').filter(i => i.trim()) + : selectedRecipe.value.ingredients, + instructions: typeof selectedRecipe.value.instructions === 'string' + ? selectedRecipe.value.instructions.split('\n').filter(i => i.trim()) + : selectedRecipe.value.instructions + }; - await $fetch(`${config.public.apiBase}api/recipe/update/${selectedRecipe.value.id}`, { + await $fetch(`${config.public.apiBase}api/recipe/update/${payload.id}`, { method: 'PUT', - credentials: 'include', - body: payload + body: payload, + credentials: 'include' }); - await fetchRecipes(); + const index = recipes.value.findIndex(r => r.id === payload.id); + if (index !== -1) { + recipes.value[index] = { ...payload }; + } + closeDetails(); } catch (e) { - console.error("Failed to update recipe:", e); - alert("Could not save changes."); + console.error("The kitchen ledger could not be updated:", e); + alert("Could not save changes. Please try again."); } } diff --git a/Seasoned.Frontend/app/pages/index.vue b/Seasoned.Frontend/app/pages/index.vue index ba0e0f8..d0b95ee 100644 --- a/Seasoned.Frontend/app/pages/index.vue +++ b/Seasoned.Frontend/app/pages/index.vue @@ -1,6 +1,6 @@