feat: complete mealplanner app (backend + frontend + deployment)
.NET 8 backend with Zitadel JWT auth, TheMealDB integration, weekly meal plan generation, shopping list aggregation. Vue 3 + Tailwind 4 frontend with dark emerald theme, manual OIDC PKCE auth, all views implemented. Multi-stage Dockerfile with nginx reverse proxy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
backend/Services/TheMealDbClient.cs
Normal file
134
backend/Services/TheMealDbClient.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Services;
|
||||
|
||||
public class TheMealDbClient(HttpClient httpClient)
|
||||
{
|
||||
private const string BaseUrl = "https://www.themealdb.com/api/json/v1/1/";
|
||||
|
||||
public async Task<Recipe?> GetRandomAsync()
|
||||
{
|
||||
var response = await httpClient.GetAsync($"{BaseUrl}random.php");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var meals = ParseMeals(json);
|
||||
return meals.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<List<Recipe>> SearchAsync(string query)
|
||||
{
|
||||
var response = await httpClient.GetAsync($"{BaseUrl}search.php?s={Uri.EscapeDataString(query)}");
|
||||
if (!response.IsSuccessStatusCode) return [];
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return ParseMeals(json);
|
||||
}
|
||||
|
||||
public async Task<Recipe?> GetByIdAsync(string id)
|
||||
{
|
||||
var response = await httpClient.GetAsync($"{BaseUrl}lookup.php?i={id}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return ParseMeals(json).FirstOrDefault();
|
||||
}
|
||||
|
||||
private static List<Recipe> ParseMeals(string json)
|
||||
{
|
||||
var result = new List<Recipe>();
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("meals", out var mealsEl) || mealsEl.ValueKind == JsonValueKind.Null)
|
||||
return result;
|
||||
|
||||
foreach (var meal in mealsEl.EnumerateArray())
|
||||
{
|
||||
var recipe = MapMeal(meal);
|
||||
if (recipe is not null)
|
||||
result.Add(recipe);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Recipe? MapMeal(JsonElement meal)
|
||||
{
|
||||
var id = meal.GetStringOrNull("idMeal");
|
||||
var title = meal.GetStringOrNull("strMeal");
|
||||
if (id is null || title is null) return null;
|
||||
|
||||
var recipe = new Recipe
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ExternalId = id,
|
||||
Title = title,
|
||||
Instructions = meal.GetStringOrNull("strInstructions") ?? "",
|
||||
ImageUrl = meal.GetStringOrNull("strMealThumb"),
|
||||
Source = RecipeSource.TheMealDb,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// Parse strIngredient1-20 / strMeasure1-20
|
||||
for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
var name = meal.GetStringOrNull($"strIngredient{i}");
|
||||
var measure = meal.GetStringOrNull($"strMeasure{i}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
|
||||
var ingredient = new RecipeIngredient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RecipeId = recipe.Id,
|
||||
Name = name.Trim(),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(measure))
|
||||
{
|
||||
ParseMeasure(measure.Trim(), out var amount, out var unit);
|
||||
ingredient.Amount = amount;
|
||||
ingredient.Unit = unit;
|
||||
}
|
||||
|
||||
recipe.Ingredients.Add(ingredient);
|
||||
}
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static void ParseMeasure(string measure, out decimal? amount, out string? unit)
|
||||
{
|
||||
amount = null;
|
||||
unit = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(measure)) return;
|
||||
|
||||
// Try to extract leading number
|
||||
var parts = measure.Trim().Split(' ', 2);
|
||||
if (parts.Length > 0 && decimal.TryParse(parts[0], System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
amount = parsed;
|
||||
unit = parts.Length > 1 ? parts[1].Trim() : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
unit = measure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file static class JsonElementExtensions
|
||||
{
|
||||
public static string? GetStringOrNull(this JsonElement el, string propertyName)
|
||||
{
|
||||
if (el.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var val = prop.GetString();
|
||||
return string.IsNullOrWhiteSpace(val) ? null : val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user