UI + backend update
This commit is contained in:
@@ -5,7 +5,6 @@ using Seasoned.Backend.Data;
|
||||
using System.Security.Claims;
|
||||
using Seasoned.Backend.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Seasoned.Backend.Controllers;
|
||||
@@ -49,7 +48,7 @@ public class RecipeController : ControllerBase
|
||||
var recipe = new Recipe
|
||||
{
|
||||
Title = recipeDto.Title,
|
||||
Description = recipeDto.Description,
|
||||
Icon = recipeDto.Icon,
|
||||
Ingredients = recipeDto.Ingredients,
|
||||
Instructions = recipeDto.Instructions,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -62,6 +61,29 @@ public class RecipeController : ControllerBase
|
||||
return Ok(new { message = "Recipe saved to your collection!" });
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPut("update/{id}")]
|
||||
public async Task<IActionResult> UpdateRecipe(int id, [FromBody] Recipe updatedRecipe)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
var existingRecipe = await _context.Recipes
|
||||
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId);
|
||||
|
||||
if (existingRecipe == null)
|
||||
{
|
||||
return NotFound("Recipe not found or you do not have permission to edit it.");
|
||||
}
|
||||
|
||||
existingRecipe.Title = updatedRecipe.Title;
|
||||
existingRecipe.Ingredients = updatedRecipe.Ingredients;
|
||||
existingRecipe.Instructions = updatedRecipe.Instructions;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "Recipe updated successfully!" });
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("my-collection")]
|
||||
public async Task<ActionResult<IEnumerable<Recipe>>> GetMyRecipes()
|
||||
@@ -75,5 +97,4 @@ public class RecipeController : ControllerBase
|
||||
|
||||
return Ok(myRecipes);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,7 +3,7 @@ namespace Seasoned.Backend.DTOs;
|
||||
public class RecipeResponseDto
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = "mdi-silverware-fork-knife";
|
||||
public List<string> Ingredients { get; set; } = new();
|
||||
public List<string> Instructions { get; set; } = new();
|
||||
}
|
||||
@@ -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? Description { get; set; }
|
||||
public string Icon { get; set; } = "mdi-silverware-fork-knife";
|
||||
public List<string> Ingredients { get; set; } = new();
|
||||
public List<string> Instructions { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Seasoned.Backend.Services;
|
||||
|
||||
public class RecipeService : IRecipeService
|
||||
@@ -18,6 +19,7 @@ public class RecipeService : IRecipeService
|
||||
public async Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image)
|
||||
{
|
||||
var googleAI = new GoogleAI(_apiKey);
|
||||
|
||||
var model = googleAI.GenerativeModel("gemini-3.1-flash-lite-preview");
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
@@ -25,16 +27,19 @@ 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.
|
||||
DO NOT include markdown formatting (no ```json).
|
||||
DO NOT include any text before or after the JSON.
|
||||
All property names and string values MUST be enclosed in double quotes.
|
||||
DO NOT include markdown formatting.
|
||||
JSON structure:
|
||||
{
|
||||
""title"": ""string"",
|
||||
""description"": ""string"",
|
||||
""ingredients"": [""string"", ""string""],
|
||||
""instructions"": [""string"", ""string""]
|
||||
""title"": ""string"",
|
||||
""icon"": ""string"",
|
||||
""ingredients"": [""string"", ""string""],
|
||||
""instructions"": [""string"", ""string""]
|
||||
}";
|
||||
|
||||
var config = new GenerationConfig {
|
||||
@@ -43,7 +48,8 @@ public class RecipeService : IRecipeService
|
||||
};
|
||||
|
||||
var request = new GenerateContentRequest(prompt, config);
|
||||
await Task.Run(() => request.AddMedia(base64Image, "image/png"));
|
||||
|
||||
await Task.Run(() => request.AddMedia(base64Image, image.ContentType ?? "image/png"));
|
||||
|
||||
var response = await model.GenerateContent(request);
|
||||
string rawText = response.Text?.Trim() ?? "";
|
||||
@@ -53,7 +59,7 @@ public class RecipeService : IRecipeService
|
||||
|
||||
if (start == -1 || end == -1)
|
||||
{
|
||||
return new RecipeResponseDto { Title = "Error", Description = "AI failed to generate a valid JSON block." };
|
||||
return new RecipeResponseDto { Title = "Error" };
|
||||
}
|
||||
|
||||
string cleanJson = rawText.Substring(start, (end - start) + 1);
|
||||
@@ -62,6 +68,12 @@ public class RecipeService : IRecipeService
|
||||
{
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var result = JsonSerializer.Deserialize<RecipeResponseDto>(cleanJson, options);
|
||||
|
||||
if (result != null && string.IsNullOrEmpty(result.Icon))
|
||||
{
|
||||
result.Icon = "mdi-silverware-fork-knife";
|
||||
}
|
||||
|
||||
return result ?? new RecipeResponseDto { Title = "Empty Response" };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@@ -69,10 +81,7 @@ public class RecipeService : IRecipeService
|
||||
Console.WriteLine($"Raw AI Output: {rawText}");
|
||||
Console.WriteLine($"Failed to parse JSON: {ex.Message}");
|
||||
|
||||
return new RecipeResponseDto {
|
||||
Title = "Parsing Error",
|
||||
Description = "The AI response was malformed. Check logs."
|
||||
};
|
||||
return new RecipeResponseDto { Title = "Parsing Error" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,25 +71,27 @@
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ingredient-item {
|
||||
border-bottom: 1px dotted #c1b18e;
|
||||
font-style: italic;
|
||||
color: #2c2925;
|
||||
}
|
||||
|
||||
.instruction-step {
|
||||
.instruction-step, .ingredient-item {
|
||||
display: flex;
|
||||
font-family: 'Libre Baskerville', serif;
|
||||
font-size: 1.1rem;
|
||||
gap: 20px;
|
||||
line-height: 1.6;
|
||||
color: #2c2925;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-family: 'Libre Baskerville', serif;
|
||||
font-weight: bold;
|
||||
color: #3b4e1e;
|
||||
font-size: 1.3rem;
|
||||
min-width: 25px;
|
||||
font-family: 'Libre Baskerville', serif !important;
|
||||
font-weight: bold !important;
|
||||
color: #3b4e1e !important;
|
||||
font-size: 1.3rem !important;
|
||||
text-align: center !important;
|
||||
min-width: 30px;
|
||||
line-height: 1.4 !important;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
@@ -237,6 +239,5 @@
|
||||
}
|
||||
|
||||
.brand-icon-container .v-img {
|
||||
/* Gives it a slightly 'stamped' look on the paper */
|
||||
filter: drop-shadow(0px 1px 1px rgba(0,0,0,0.1));
|
||||
}
|
||||
@@ -48,3 +48,77 @@
|
||||
background-color: #4a3a2a !important;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2) !important;
|
||||
}
|
||||
|
||||
.recipe-title-edit .v-field__input {
|
||||
font-family: 'Libre Baskerville', serif !important;
|
||||
font-size: 2.4rem !important;
|
||||
color: #1e1408 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.v-textarea .v-field {
|
||||
background-color: rgba(93, 64, 55, 0.05) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.save-btn, .cancel-btn {
|
||||
font-family: 'Libre Baskerville', serif !important;
|
||||
text-transform: none !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.1rem !important;
|
||||
letter-spacing: 0px !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
color: #556b2f !important;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background-color: rgba(85, 107, 47, 0.08) !important;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
color: #8c4a32 !important;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background-color: rgba(140, 74, 50, 0.05) !important;
|
||||
}
|
||||
|
||||
.recipe-title-edit .v-field__input {
|
||||
font-family: 'Libre Baskerville', serif !important;
|
||||
font-weight: 700 !important;
|
||||
color: #1e1408 !important;
|
||||
font-size: 2.4rem !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.v-textarea .v-field__input {
|
||||
font-weight: 500 !important;
|
||||
color: #2c2925 !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
.v-field-label {
|
||||
color: #5d4037 !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
.v-field__outline {
|
||||
--v-field-border-opacity: 1 !important;
|
||||
color: #d1c7b7 !important;
|
||||
}
|
||||
|
||||
.v-field--focused .v-field__outline {
|
||||
color: #556b2f !important;
|
||||
}
|
||||
|
||||
.recipe-title-edit.v-text-field .v-field__outline__line {
|
||||
border-bottom-width: 2px !important;
|
||||
color: #d1c7b7 !important;
|
||||
}
|
||||
|
||||
.recipe-title-edit.v-field--focused .v-field__outline__line {
|
||||
color: #556b2f !important;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<v-card class="recipe-card pa-10 mx-auto mt-10" max-width="1200" elevation="1">
|
||||
|
||||
<header class="text-center mb-10">
|
||||
<h1 class="brand-title">The Collection</h1>
|
||||
<h1 class="brand-title">Your Collection</h1>
|
||||
<p class="brand-subtitle">Hand-Picked & Seasoned</p>
|
||||
</header>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<v-row v-if="loading" justify="center" class="py-16">
|
||||
<v-col cols="12" class="d-flex flex-column align-center">
|
||||
<v-progress-circular indeterminate color="#556b2f" size="64" width="3"></v-progress-circular>
|
||||
<p class="brand-subtitle mt-4">Opening the Ledger...</p>
|
||||
<p class="brand-subtitle mt-4">Opening Collection...</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
style="border: 1px solid #e8e2d6;"
|
||||
>
|
||||
<v-icon
|
||||
:icon="getRecipeIcon(recipe.title)"
|
||||
:icon="getRecipeIcon(recipe)"
|
||||
size="80"
|
||||
color="#d1c7b7"
|
||||
></v-icon>
|
||||
@@ -48,14 +48,24 @@
|
||||
</p>
|
||||
|
||||
<v-card-actions class="justify-center">
|
||||
<v-btn
|
||||
variant="text"
|
||||
class="view-recipe-btn"
|
||||
color="#556b2f"
|
||||
@click="openRecipe(recipe)"
|
||||
>
|
||||
Open Recipe
|
||||
</v-btn>
|
||||
<v-card-actions class="justify-center">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="#556b2f"
|
||||
class="save-btn"
|
||||
@click="openRecipe(recipe)"
|
||||
>
|
||||
Open
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="#8c4a32"
|
||||
class="cancel-btn"
|
||||
@click="editRecipe(recipe)"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -64,22 +74,129 @@
|
||||
<v-row v-else justify="center" class="py-10 text-center">
|
||||
<v-col cols="12">
|
||||
<p class="brand-subtitle mb-4">Your collection is empty.</p>
|
||||
<v-btn to="/" variant="text" color="#556b2f">Return to kitchen to add some</v-btn>
|
||||
<v-btn to="/" variant="text" color="#556b2f">Return Home to add some</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="showDetails" max-width="800" persistent>
|
||||
<v-card v-if="selectedRecipe" class="recipe-card pa-8">
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
position="absolute"
|
||||
style="top: 10px; right: 10px;"
|
||||
color="#5d4037"
|
||||
@click="closeDetails"
|
||||
></v-btn>
|
||||
|
||||
<header class="text-center mb-6">
|
||||
<v-text-field
|
||||
v-if="isEditing"
|
||||
v-model="selectedRecipe.title"
|
||||
variant="underlined"
|
||||
class="recipe-title-edit"
|
||||
></v-text-field>
|
||||
<h2 v-else class="recipe-title">{{ selectedRecipe.title }}</h2>
|
||||
</header>
|
||||
|
||||
<v-divider class="mb-6 separator"></v-divider>
|
||||
|
||||
<v-row justify="center" class="px-md-10">
|
||||
<v-col cols="12" md="5" class="d-flex flex-column align-center">
|
||||
<div style="width: 100%; max-width: 300px;">
|
||||
<h3 class="section-header mb-4">
|
||||
<v-icon icon="mdi-basket-outline" class="mr-2" size="small"></v-icon>
|
||||
Ingredients
|
||||
</h3>
|
||||
|
||||
<v-textarea
|
||||
v-if="isEditing"
|
||||
v-model="selectedRecipe.ingredients"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
bg-color="rgba(255,255,255,0.3)"
|
||||
></v-textarea>
|
||||
|
||||
<v-list v-else class="ingredients-list">
|
||||
<v-list-item
|
||||
v-for="(ing, index) in selectedRecipe.ingredients?.split('\n').filter(i => i.trim())"
|
||||
:key="index"
|
||||
class="ingredient-item px-0"
|
||||
>
|
||||
{{ ing }}
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="7">
|
||||
<h3 class="section-header mb-4">
|
||||
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
|
||||
Instructions
|
||||
</h3>
|
||||
|
||||
<v-textarea
|
||||
v-if="isEditing"
|
||||
v-model="selectedRecipe.instructions"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
bg-color="rgba(255,255,255,0.3)"
|
||||
></v-textarea>
|
||||
|
||||
<div v-else
|
||||
v-for="(step, index) in selectedRecipe.instructions?.split('\n').filter(s => s.trim())"
|
||||
:key="index"
|
||||
class="instruction-step mb-4"
|
||||
>
|
||||
<span class="step-number">{{ index + 1 }}</span>
|
||||
<p>{{ step }}</p>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card-actions v-if="isEditing" class="justify-center mt-6">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="saveChanges"
|
||||
class="save-btn px-8"
|
||||
>
|
||||
Save Changes
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="isEditing = false"
|
||||
class="cancel-btn px-8"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
<v-divider class="my-6 separator"></v-divider>
|
||||
|
||||
<footer class="text-center">
|
||||
<p class="brand-subtitle" style="font-size: 0.8rem;">
|
||||
Recorded on {{ new Date(selectedRecipe.createdAt).toLocaleDateString() }}
|
||||
</p>
|
||||
</footer>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import '@/assets/css/gallery.css'
|
||||
const config = useRuntimeConfig()
|
||||
const recipes = ref([])
|
||||
const loading = ref(true)
|
||||
const showDetails = ref(false)
|
||||
const selectedRecipe = ref(null)
|
||||
const isEditing = ref(false)
|
||||
const originalRecipe = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRecipes()
|
||||
@@ -108,4 +225,84 @@ const fetchRecipes = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const openRecipe = (recipe) => {
|
||||
selectedRecipe.value = { ...recipe }
|
||||
isEditing.value = false
|
||||
showDetails.value = true
|
||||
}
|
||||
|
||||
const editRecipe = (recipe) => {
|
||||
selectedRecipe.value = { ...recipe }
|
||||
originalRecipe.value = { ...recipe }
|
||||
isEditing.value = true
|
||||
showDetails.value = true
|
||||
}
|
||||
|
||||
const closeDetails = () => {
|
||||
showDetails.value = false
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
const token = useCookie('seasoned_token').value
|
||||
|
||||
try {
|
||||
await $fetch(`${config.public.apiBase}api/recipe/update/${selectedRecipe.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: selectedRecipe.value
|
||||
})
|
||||
|
||||
await fetchRecipes()
|
||||
|
||||
isEditing.value = false
|
||||
showDetails.value = false
|
||||
} catch (e) {
|
||||
console.error("Failed to update recipe:", e)
|
||||
alert("Could not save changes. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// Mock setup for recipe cards
|
||||
//const mockData = [
|
||||
// {
|
||||
// id: 1,
|
||||
// title: "Grandma's Secret Bolognese",
|
||||
// createdAt: new Date().toISOString(),
|
||||
// ingredients: "2 lbs Ground Beef\n1 Onion, diced\n3 cloves Garlic\n2 cans Crushed Tomatoes\n1 cup Red Wine",
|
||||
// instructions: "Brown the meat with onions.\nAdd garlic and wine; reduce.\nSimmer with tomatoes for 3 hours."
|
||||
// },
|
||||
// {
|
||||
//id: 2,
|
||||
//title: "Rustic Sourdough",
|
||||
//createdAt: new Date().toISOString(),
|
||||
//ingredients: "500g Flour\n350g Water\n100g Starter\n10g Salt",
|
||||
//instructions: "Mix and autolyse for 1 hour.\nPerform 4 sets of stretch and folds.\nCold ferment for 12 hours.\nBake at 450°F in a dutch oven."
|
||||
//}
|
||||
//]
|
||||
//recipes.value = mockData
|
||||
//loading.value = false
|
||||
//}
|
||||
|
||||
//const saveChanges = async () => {
|
||||
|
||||
//const index = recipes.value.findIndex(r => r.id === selectedRecipe.value.id)
|
||||
//if (index !== -1) {
|
||||
//recipes.value[index] = { ...selectedRecipe.value }
|
||||
//}
|
||||
|
||||
//isEditing.value = false
|
||||
//showDetails.value = false
|
||||
//}
|
||||
|
||||
const getRecipeIcon = (title) => {
|
||||
const t = title.toLowerCase()
|
||||
if (recipe.icon) return recipe.icon
|
||||
if (t.includes('cake') || t.includes('cookie') || t.includes('dessert')) return 'mdi-cookie'
|
||||
if (t.includes('soup') || t.includes('stew')) return 'mdi-bowl-mix'
|
||||
if (t.includes('drink') || t.includes('cocktail')) return 'mdi-glass-cocktail'
|
||||
return 'mdi-silverware-fork-knife'
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user