Compare commits
9 Commits
0479f25db4
...
ecfa1b6dcb
| Author | SHA1 | Date | |
|---|---|---|---|
| ecfa1b6dcb | |||
| 0c4126d860 | |||
| 300aaab167 | |||
| ae4f4dd2dc | |||
| fd3f1a6db7 | |||
| c065cbf61e | |||
|
|
a24e901b7f | ||
|
|
dca065517e | ||
|
|
5ccc4dca8c |
3
.gitconfig
Normal file
3
.gitconfig
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[user]
|
||||||
|
email = chloestanton117@gmail.com
|
||||||
|
name = chloe
|
||||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -1,24 +1,8 @@
|
|||||||
# Nuxt dev/build outputs
|
bin/
|
||||||
.output
|
obj/
|
||||||
.data
|
Seasoned.Frontend/node_modules/
|
||||||
.nuxt
|
.nuxt/
|
||||||
.nitro
|
.output/
|
||||||
.cache
|
dist/
|
||||||
dist
|
.nvm/
|
||||||
|
|
||||||
# Node dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
.fleet
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Local env files
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Stage 1: Build the Nuxt 4 frontend (Debian Slim)
|
||||||
|
FROM node:20-bookworm-slim AS frontend-build
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY Seasoned.Frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY Seasoned.Frontend/ .
|
||||||
|
RUN npx nuxi generate
|
||||||
|
|
||||||
|
# Stage 2: Build the .NET 9 backend (Debian Slim)
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS backend-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY Seasoned.Backend/*.csproj ./Seasoned.Backend/
|
||||||
|
RUN dotnet restore ./Seasoned.Backend/Seasoned.Backend.csproj
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Copy Nuxt static files into .NET wwwroot
|
||||||
|
COPY --from=frontend-build /src/frontend/.output/public ./Seasoned.Backend/wwwroot
|
||||||
|
|
||||||
|
RUN dotnet publish ./Seasoned.Backend/Seasoned.Backend.csproj -c Release -o /app
|
||||||
|
|
||||||
|
# Stage 3: Final Runtime (Debian Slim)
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend-build /app .
|
||||||
|
|
||||||
|
# Essential for PGVector and Gemini logic to run smoothly
|
||||||
|
ENV ASPNETCORE_URLS=http://+:5000
|
||||||
|
ENTRYPOINT ["dotnet", "Seasoned.Backend.dll"]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The Pitch:
|
The Pitch:
|
||||||
|
|
||||||
Seasoned is a high-performance, private digital cookbook that bridges the gap between web discovery and kitchen execution. By combining the multimodal power of Gemini 1.5 Flash with a secure, self-hosted PostgreSQL backbone, Seasoned allows users to instantly "distill" messy recipe blogs and food photos into a standardized, searchable, and shareable library they truly own.
|
Seasoned is a high-performance, private digital cookbook that bridges the gap between web discovery and kitchen execution. By combining the multimodal power of Gemini 2.5 Flash with a secure, self-hosted PostgreSQL backbone, Seasoned allows users to instantly "distill" messy recipe blogs and food photos into a standardized, searchable, and shareable library they truly own.
|
||||||
|
|
||||||
Target Audience:
|
Target Audience:
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@ The Hybrid Tech Stack:
|
|||||||
| **Hosting** | Private Server (Dockerized on home hardware) |
|
| **Hosting** | Private Server (Dockerized on home hardware) |
|
||||||
| **CI/CD** | Jenkins server |
|
| **CI/CD** | Jenkins server |
|
||||||
| **Frontend** | Nuxt 4 + Vuetify + CSS |
|
| **Frontend** | Nuxt 4 + Vuetify + CSS |
|
||||||
| **Backend** | Nuxt Nitro |
|
| **Backend** | Dotnet |
|
||||||
| **Database** | Postgres + pgvector |
|
| **Database** | Postgres + pgvector |
|
||||||
| **Intelligence** | Gemini 1.5 Flash |
|
| **Intelligence** | Gemini 2.5 Flash |
|
||||||
| **Storage** | Local File System |
|
| **Storage** | Local File System |
|
||||||
|
|
||||||
Technical Requirements:
|
Technical Requirements:
|
||||||
|
|||||||
29
Seasoned.Backend/Controllers/RecipeController.cs
Normal file
29
Seasoned.Backend/Controllers/RecipeController.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Seasoned.Backend.Services;
|
||||||
|
using Seasoned.Backend.DTOs;
|
||||||
|
|
||||||
|
namespace Seasoned.Backend.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/recipe")]
|
||||||
|
public class RecipeController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRecipeService _recipeService;
|
||||||
|
|
||||||
|
public RecipeController(IRecipeService recipeService)
|
||||||
|
{
|
||||||
|
_recipeService = recipeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("upload")]
|
||||||
|
public async Task<ActionResult<RecipeResponseDto>> UploadRecipe([FromForm] IFormFile image)
|
||||||
|
{
|
||||||
|
if (image == null || image.Length == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("No image uploaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _recipeService.ParseRecipeImageAsync(image);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Seasoned.Backend/DTOs/RecipeResponseDto.cs
Normal file
9
Seasoned.Backend/DTOs/RecipeResponseDto.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Seasoned.Backend.DTOs;
|
||||||
|
|
||||||
|
public class RecipeResponseDto
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public List<string> Ingredients { get; set; } = new();
|
||||||
|
public List<string> Instructions { get; set; } = new();
|
||||||
|
}
|
||||||
17
Seasoned.Backend/Data/ApplicationDbContext.cs
Normal file
17
Seasoned.Backend/Data/ApplicationDbContext.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Seasoned.Backend.Models;
|
||||||
|
|
||||||
|
namespace Seasoned.Backend.Data;
|
||||||
|
|
||||||
|
public class ApplicationDbContext : DbContext
|
||||||
|
{
|
||||||
|
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
: base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Recipe> Recipes { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasPostgresExtension("vector");
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.Designer.cs
generated
Normal file
64
Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.Designer.cs
generated
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Seasoned.Backend.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Seasoned.Backend.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260305044721_SeasonedInit")]
|
||||||
|
partial class SeasonedInit
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Ingredients")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Instructions")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Recipes");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Seasoned.Backend.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SeasonedInit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Recipes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Title = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Ingredients = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
|
Instructions = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Recipes", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Recipes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Seasoned.Backend.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Seasoned.Backend.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.2")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Ingredients")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<List<string>>("Instructions")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Recipes");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Seasoned.Backend/Models/Recipe.cs
Normal file
11
Seasoned.Backend/Models/Recipe.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Seasoned.Backend.Models;
|
||||||
|
|
||||||
|
public class Recipe
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public List<string> Ingredients { get; set; } = new();
|
||||||
|
public List<string> Instructions { get; set; } = new();
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
51
Seasoned.Backend/Program.cs
Normal file
51
Seasoned.Backend/Program.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using Seasoned.Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Seasoned.Backend.Data;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IRecipeService, RecipeService>();
|
||||||
|
|
||||||
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(options => {
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowAll", policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
o => o.UseVector()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
app.Run();
|
||||||
14
Seasoned.Backend/Properties/launchSettings.json
Normal file
14
Seasoned.Backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5243",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Seasoned.Backend/Seasoned.Backend.csproj
Normal file
22
Seasoned.Backend/Seasoned.Backend.csproj
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>71e44981-0c1f-4d90-8e87-317af8bd03ce</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
|
||||||
|
<PackageReference Include="Mscc.GenerativeAI" Version="2.2.8" />
|
||||||
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Seasoned.Backend/Seasoned.Backend.http
Normal file
6
Seasoned.Backend/Seasoned.Backend.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@Seasoned.Backend_HostAddress = http://localhost:5243
|
||||||
|
|
||||||
|
GET {{Seasoned.Backend_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
8
Seasoned.Backend/Services/IRecipeService.cs
Normal file
8
Seasoned.Backend/Services/IRecipeService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using Seasoned.Backend.DTOs;
|
||||||
|
|
||||||
|
namespace Seasoned.Backend.Services;
|
||||||
|
|
||||||
|
public interface IRecipeService
|
||||||
|
{
|
||||||
|
Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image);
|
||||||
|
}
|
||||||
78
Seasoned.Backend/Services/RecipeService.cs
Normal file
78
Seasoned.Backend/Services/RecipeService.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Seasoned.Backend.DTOs;
|
||||||
|
using Mscc.GenerativeAI;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
namespace Seasoned.Backend.Services;
|
||||||
|
|
||||||
|
public class RecipeService : IRecipeService
|
||||||
|
{
|
||||||
|
private readonly string _apiKey;
|
||||||
|
|
||||||
|
public RecipeService(IConfiguration config)
|
||||||
|
{
|
||||||
|
_apiKey = config["GEMINI_API_KEY"] ?? throw new ArgumentNullException("API Key missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image)
|
||||||
|
{
|
||||||
|
var googleAI = new GoogleAI(_apiKey);
|
||||||
|
var model = googleAI.GenerativeModel("gemini-2.5-flash");
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await image.CopyToAsync(ms);
|
||||||
|
var base64Image = Convert.ToBase64String(ms.ToArray());
|
||||||
|
|
||||||
|
var prompt = @"Extract the recipe details from this image.
|
||||||
|
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.
|
||||||
|
JSON structure:
|
||||||
|
{
|
||||||
|
""title"": ""string"",
|
||||||
|
""description"": ""string"",
|
||||||
|
""ingredients"": [""string"", ""string""],
|
||||||
|
""instructions"": [""string"", ""string""]
|
||||||
|
}";
|
||||||
|
|
||||||
|
var config = new GenerationConfig {
|
||||||
|
ResponseMimeType = "application/json",
|
||||||
|
Temperature = 0.1f
|
||||||
|
};
|
||||||
|
|
||||||
|
var request = new GenerateContentRequest(prompt, config);
|
||||||
|
await Task.Run(() => request.AddMedia(base64Image, "image/png"));
|
||||||
|
|
||||||
|
var response = await model.GenerateContent(request);
|
||||||
|
string rawText = response.Text?.Trim() ?? "";
|
||||||
|
|
||||||
|
int start = rawText.IndexOf('{');
|
||||||
|
int end = rawText.LastIndexOf('}');
|
||||||
|
|
||||||
|
if (start == -1 || end == -1)
|
||||||
|
{
|
||||||
|
return new RecipeResponseDto { Title = "Error", Description = "AI failed to generate a valid JSON block." };
|
||||||
|
}
|
||||||
|
|
||||||
|
string cleanJson = rawText.Substring(start, (end - start) + 1);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var result = JsonSerializer.Deserialize<RecipeResponseDto>(cleanJson, options);
|
||||||
|
return result ?? new RecipeResponseDto { Title = "Empty Response" };
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
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."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Seasoned.Backend/appsettings.Development.json
Normal file
8
Seasoned.Backend/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Seasoned.Backend/appsettings.json
Normal file
9
Seasoned.Backend/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
24
Seasoned.Frontend/.gitignore
vendored
Normal file
24
Seasoned.Frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
1
Seasoned.Frontend/.nuxtrc
Normal file
1
Seasoned.Frontend/.nuxtrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
telemetry.enabled=false
|
||||||
14
Seasoned.Frontend/Seasoned.code-workspace
Normal file
14
Seasoned.Frontend/Seasoned.code-workspace
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../Seasoned.Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../Seasoned.Tests"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
104
Seasoned.Frontend/app/app.vue
Normal file
104
Seasoned.Frontend/app/app.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<v-app class="recipe-bg">
|
||||||
|
<v-main>
|
||||||
|
<v-container>
|
||||||
|
<v-card class="recipe-card pa-10 mx-auto mt-10" max-width="950" elevation="1">
|
||||||
|
|
||||||
|
<header class="text-center mb-10">
|
||||||
|
<h1 class="brand-title">Seasoned</h1>
|
||||||
|
<p class="brand-subtitle">A Recipe Collection</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<v-divider class="mb-10 separator"></v-divider>
|
||||||
|
|
||||||
|
<v-row justify="center" class="mb-12">
|
||||||
|
<v-col cols="12" md="8">
|
||||||
|
<v-file-input
|
||||||
|
v-model="files"
|
||||||
|
label="Upload Image"
|
||||||
|
variant="solo-filled"
|
||||||
|
flat
|
||||||
|
accept="image/*"
|
||||||
|
class="custom-input mb-4"
|
||||||
|
></v-file-input>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
class="analyze-btn"
|
||||||
|
block
|
||||||
|
size="x-large"
|
||||||
|
elevation="0"
|
||||||
|
:loading="loading"
|
||||||
|
@click="uploadImage"
|
||||||
|
>
|
||||||
|
Analyze Recipe
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="recipe" class="recipe-content">
|
||||||
|
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
|
||||||
|
<p class="recipe-description text-center mb-12 text-italic">{{ recipe.description }}</p>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="5">
|
||||||
|
<div class="section-header mb-6 px-2">
|
||||||
|
<v-icon icon="mdi-spoon-sugar" class="mr-2" size="small"></v-icon>
|
||||||
|
<span>Ingredients</span>
|
||||||
|
</div>
|
||||||
|
<v-list class="ingredients-list">
|
||||||
|
<v-list-item v-for="(item, i) in recipe.ingredients" :key="i" class="ingredient-item">
|
||||||
|
{{ item }}
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="7">
|
||||||
|
<div class="section-header mb-6 px-2">
|
||||||
|
<v-icon icon="mdi-pot-steam-outline" class="mr-2" size="small"></v-icon>
|
||||||
|
<span>Instructions</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="(step, i) in recipe.instructions" :key="i" class="instruction-step mb-8">
|
||||||
|
<span class="step-number">{{ i + 1 }}.</span>
|
||||||
|
<p class="step-text">{{ step }}</p>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</v-card>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import axios from 'axios'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import '@/assets/css/app-theme.css'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const files = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const recipe = ref(null)
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
const fileToUpload = Array.isArray(files.value) ? files.value[0] : files.value;
|
||||||
|
if (!fileToUpload) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', fileToUpload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch(`${config.public.apiBase}api/recipe/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
recipe.value = response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
116
Seasoned.Frontend/app/assets/css/app-theme.css
Normal file
116
Seasoned.Frontend/app/assets/css/app-theme.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600&display=swap');
|
||||||
|
|
||||||
|
.recipe-bg {
|
||||||
|
background-color: #3e2a14 !important;
|
||||||
|
background-image: url("https://www.transparenttextures.com/patterns/dark-wood.png") !important; /* Richer wood texture */
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card {
|
||||||
|
background-color: #f4e4bc !important;
|
||||||
|
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png"); /* Stronger linen texture */
|
||||||
|
border: 1px solid #c9b996 !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: 'Libre Baskerville', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 2.8rem;
|
||||||
|
color: #2e1e0a;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-family: 'Libre Baskerville', serif;
|
||||||
|
font-style: italic;
|
||||||
|
color: #6d5e4a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-title {
|
||||||
|
font-family: 'Libre Baskerville', serif;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
color: #1e1408;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-family: 'Libre Baskerville', serif;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-bottom: 2px solid #dccca7;
|
||||||
|
color: #4a3a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input .v-field {
|
||||||
|
background-color: #5d4037 !important;
|
||||||
|
color: #f8f1e0 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
border: 2px solid #5d4037 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input .v-label {
|
||||||
|
color: #f8f1e0 !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input .v-icon {
|
||||||
|
color: #f8f1e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input .v-field--focused {
|
||||||
|
border: 2px solid #556b2f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyze-btn {
|
||||||
|
background-color: #556b2f !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-family: 'Libre Baskerville', serif;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredients-list {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-item {
|
||||||
|
border-bottom: 1px dotted #c1b18e;
|
||||||
|
font-style: italic;
|
||||||
|
color: #2c2925;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #2c2925;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-family: 'Libre Baskerville', serif;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #3b4e1e;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
min-width: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
border-color: #dccca7 !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active {
|
||||||
|
transition: opacity 0.6s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from, .fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
40
Seasoned.Frontend/nuxt.config.ts
Normal file
40
Seasoned.Frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
future: {
|
||||||
|
compatibilityVersion: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
srcDir: 'app/',
|
||||||
|
|
||||||
|
css: [
|
||||||
|
'vuetify/lib/styles/main.sass',
|
||||||
|
'@mdi/font/css/materialdesignicons.min.css',
|
||||||
|
],
|
||||||
|
|
||||||
|
build: {
|
||||||
|
transpile: ['vuetify'],
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'vuetify-nuxt-module'
|
||||||
|
],
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
hmr: process.env.NODE_ENV !== 'production' ? {
|
||||||
|
protocol: 'ws',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3000
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
3254
package-lock.json → Seasoned.Frontend/package-lock.json
generated
3254
package-lock.json → Seasoned.Frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
Seasoned.Frontend/package.json
Normal file
35
Seasoned.Frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "Seasoned",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"@prisma/client": "^7.4.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"nuxt": "^4.1.3",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
|
"sass": "^1.97.3",
|
||||||
|
"vue": "^3.5.29",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
|
"vuetify": "^4.0.1",
|
||||||
|
"vuetify-nuxt-module": "^0.19.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"happy-dom": "^20.8.3",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Seasoned.Frontend/plugins/vuetify.ts
Normal file
14
Seasoned.Frontend/plugins/vuetify.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
ssr: true,
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(vuetify)
|
||||||
|
})
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
8
Seasoned.Frontend/test/App.spec.ts
Normal file
8
Seasoned.Frontend/test/App.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
describe('Frontend Setup', () => {
|
||||||
|
it('checks that 1 + 1 is 2', () => {
|
||||||
|
expect(1 + 1).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
16
Seasoned.Frontend/vitest.config.ts
Normal file
16
Seasoned.Frontend/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// vitest.config.ts
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
inline: [/@exodus\/bytes/, /html-encoding-sniffer/],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
27
Seasoned.Tests/Seasoned.Tests.csproj
Normal file
27
Seasoned.Tests/Seasoned.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Seasoned.Backend\Seasoned.Backend.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
47
Seasoned.Tests/UnitTest1.cs
Normal file
47
Seasoned.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Seasoned.Backend.Controllers;
|
||||||
|
using Seasoned.Backend.Services;
|
||||||
|
using Seasoned.Backend.DTOs;
|
||||||
|
|
||||||
|
namespace Seasoned.Tests;
|
||||||
|
|
||||||
|
public class RecipeControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ParseRecipe_ReturnsOk_WhenImageIsValid()
|
||||||
|
{
|
||||||
|
// 1. Arrange: Create a "Fake" Service
|
||||||
|
var mockService = new Mock<IRecipeService>();
|
||||||
|
var fakeRecipe = new RecipeResponseDto { Title = "Test Recipe" };
|
||||||
|
|
||||||
|
mockService.Setup(s => s.ParseImageAsync(It.IsAny<IFormFile>()))
|
||||||
|
.ReturnsAsync(fakeRecipe);
|
||||||
|
|
||||||
|
var controller = new RecipeController(mockService.Object);
|
||||||
|
|
||||||
|
// Create a fake image file
|
||||||
|
var content = "fake image content";
|
||||||
|
var fileName = "test.jpg";
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
var writer = new StreamWriter(ms);
|
||||||
|
writer.Write(content);
|
||||||
|
writer.Flush();
|
||||||
|
ms.Position = 0;
|
||||||
|
var mockFile = new FormFile(ms, 0, ms.Length, "id_from_form", fileName);
|
||||||
|
|
||||||
|
// 2. Act: Call the Controller
|
||||||
|
var result = await controller.ParseRecipe(mockFile);
|
||||||
|
|
||||||
|
// 3. Assert: Check the result
|
||||||
|
var okResult = result as OkObjectResult;
|
||||||
|
okResult.Should().NotBeNull();
|
||||||
|
okResult!.StatusCode.Should().Be(200);
|
||||||
|
|
||||||
|
var returnedRecipe = okResult.Value as RecipeResponseDto;
|
||||||
|
returnedRecipe!.Title.Should().Be("Test Recipe");
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Seasoned.code-workspace
Normal file
7
Seasoned.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
Seasoned.sln
Normal file
30
Seasoned.sln
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seasoned.Tests", "Seasoned.Tests\Seasoned.Tests.csproj", "{FEF19240-CC2D-9BD4-7AC8-277372525020}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seasoned.Backend", "Seasoned.Backend\Seasoned.Backend.csproj", "{874CAB7B-A43B-C80D-494C-C243C69AEE7A}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {ED9A436F-5793-4685-B738-3AA5653D4AF1}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<NuxtRouteAnnouncer />
|
|
||||||
<NuxtWelcome />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
34
compose.yaml
Normal file
34
compose.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
# Database: Postgres with pgvector
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: seasoned-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./init-db:/docker-entrypoint-initdb.d
|
||||||
|
|
||||||
|
# Seasoned App: C# .NET 9 + Nuxt 4
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: seasoned-main
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "5000:8080"
|
||||||
|
environment:
|
||||||
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
|
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE}
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASSWORD}
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
- ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
compatibilityDate: '2025-07-15',
|
|
||||||
devtools: { enabled: true },
|
|
||||||
future: {
|
|
||||||
compatibilityVersion: 4,
|
|
||||||
},
|
|
||||||
srcDir: 'app/',
|
|
||||||
})
|
|
||||||
17
package.json
17
package.json
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Seasoned",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "nuxt build",
|
|
||||||
"dev": "nuxt dev",
|
|
||||||
"generate": "nuxt generate",
|
|
||||||
"preview": "nuxt preview",
|
|
||||||
"postinstall": "nuxt prepare"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"nuxt": "^4.3.1",
|
|
||||||
"vue": "^3.5.29",
|
|
||||||
"vue-router": "^4.6.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user