Initial commit

This commit is contained in:
2026-06-04 06:24:56 +02:00
commit 282f4864c4
111 changed files with 8083 additions and 0 deletions

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# =============================================================================
# DailyMeals environment configuration
# Copy this file to ".env" and fill in real values. Never commit ".env".
# =============================================================================
# ---- Database (external PostgreSQL server) ----------------------------------
# PostgreSQL connection string in Npgsql format. The database already exists and
# is already populated — the app only connects, it does not create or migrate it.
# Example:
DB_CONNECTION_STRING=Host=your-db-host;Port=5432;Database=mealplan;Username=user;Password=secret
# ---- JWT authentication -----------------------------------------------------
# Secret signing key for JWT access tokens. MUST be at least 32 characters.
# Generate one with: openssl rand -base64 48
JWT_SECRET=replace-with-a-long-random-secret-at-least-32-chars
# Token issuer and audience (any stable identifiers for your deployment).
JWT_ISSUER=DailyMeals
JWT_AUDIENCE=DailyMeals
# ---- CORS -------------------------------------------------------------------
# The exact origin the browser uses to reach the frontend. The API only allows
# cross-origin requests from this origin. Behind the bundled nginx (same origin)
# CORS is not strictly required, but set it to your public URL for safety.
# Example for local docker compose: https://localhost
# Production: https://meals.example.com (must match the browser URL exactly)
CORS_ALLOWED_ORIGIN=https://localhost
# ---- Production TLS (optional, Linux server with Let's Encrypt) --------------
# Used with docker-compose.tls.yml — paths on the HOST, mounted into nginx.
# TLS_CERT_PATH=/etc/letsencrypt/live/meals.example.com/fullchain.pem
# TLS_KEY_PATH=/etc/letsencrypt/live/meals.example.com/privkey.pem

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Secrets
.env
**/appsettings.Local.json
# .NET
**/bin/
**/obj/
**/logs/
*.user
# Node / frontend
**/node_modules/
**/dist/
# IDE / OS
.vs/
.idea/*
!.idea/.idea.DailyMeals/
.idea/.idea.DailyMeals/*
!.idea/.idea.DailyMeals/.idea/
.idea/.idea.DailyMeals/.idea/*
!.idea/.idea.DailyMeals/.idea/runConfigurations/
!.idea/.idea.DailyMeals/.idea/runConfigurations/**
.toolchain/
.DS_Store
*.log

View File

@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MealPlan.Api (http)" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/MealPlan.Api/MealPlan.Api.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="DOTNET_LAUNCH_OPTIONS_MODIFICATION_KIND" value="None" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="true" type="CompoundRunConfigurationType">
<toRun name="MealPlan.Api (http)" type="LaunchSettings" />
<toRun name="meal-plan-frontend (vite dev)" type="js.build_tools.npm" />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="meal-plan-frontend (vite dev)" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/meal-plan-frontend/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="$PROJECT_DIR$/.toolchain/node-v22.22.0-darwin-arm64/bin/node" />
<package-manager value="$PROJECT_DIR$/.toolchain/node-v22.22.0-darwin-arm64/lib/node_modules/npm" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MealPlan.Api (http)" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/MealPlan.Api/MealPlan.Api.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="DOTNET_LAUNCH_OPTIONS_MODIFICATION_KIND" value="None" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="meal-plan-frontend (vite dev)" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/meal-plan-frontend/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="$PROJECT_DIR$/.toolchain/node-v22.22.0-darwin-arm64/bin/node" />
<package-manager value="$PROJECT_DIR$/.toolchain/node-v22.22.0-darwin-arm64/lib/node_modules/npm" />
<envs />
<method v="2" />
</configuration>
</component>

26
DailyMeals.sln Normal file
View File

@@ -0,0 +1,26 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MealPlan.Api", "MealPlan.Api\MealPlan.Api.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
ProjectSection(SolutionItems) = preProject
README.md = README.md
.env.example = .env.example
scripts\install-node.sh = scripts\install-node.sh
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,9 @@
bin/
obj/
logs/
*.user
.vs/
.idea/
Dockerfile
.dockerignore
appsettings.Development.json

View File

@@ -0,0 +1,100 @@
using System.Security.Claims;
using MealPlan.Api.DTOs.Auth;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService) => _authService = authService;
/// <summary>Creates a new user account and returns an access + refresh token pair.</summary>
[HttpPost("register")]
[AllowAnonymous]
[EnableRateLimiting("login")]
[ProducesResponseType(typeof(TokenResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Register([FromBody] RegisterRequestDto request, CancellationToken ct)
{
var result = await _authService.RegisterAsync(request, ct);
if (!result.Succeeded)
{
var status = result.Error?.Contains("already exists", StringComparison.OrdinalIgnoreCase) == true
? StatusCodes.Status409Conflict
: StatusCodes.Status400BadRequest;
return StatusCode(status, new { error = result.Error });
}
return StatusCode(StatusCodes.Status201Created, result.Tokens);
}
/// <summary>Authenticates a user and returns an access + refresh token pair.</summary>
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("login")]
[ProducesResponseType(typeof(TokenResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken ct)
{
var result = await _authService.LoginAsync(request, ct);
if (!result.Succeeded)
{
return Unauthorized(new { error = result.Error });
}
return Ok(result.Tokens);
}
/// <summary>Rotates a refresh token and issues a fresh token pair.</summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(TokenResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh([FromBody] RefreshRequestDto request, CancellationToken ct)
{
var result = await _authService.RefreshAsync(request.RefreshToken, ct);
if (!result.Succeeded)
{
return Unauthorized(new { error = result.Error });
}
return Ok(result.Tokens);
}
/// <summary>Revokes the supplied refresh token. Always returns 204.</summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult Logout([FromBody] RefreshRequestDto request)
{
_authService.Logout(request.RefreshToken);
return NoContent();
}
/// <summary>Returns the currently authenticated user's profile.</summary>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(UserInfoDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Me(CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
{
return Unauthorized();
}
var info = await _authService.GetUserInfoAsync(userId, ct);
return info is null ? Unauthorized() : Ok(info);
}
}

View File

@@ -0,0 +1,54 @@
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/recipes")]
[Authorize]
public class RecipesController : ControllerBase
{
private readonly IRecipeService _recipeService;
public RecipesController(IRecipeService recipeService) => _recipeService = recipeService;
/// <summary>Lists active recipes, optionally filtered by category and/or name search.</summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRecipes(
[FromQuery] int? category,
[FromQuery] string? search,
CancellationToken ct)
{
if (category is < 0 or > 3)
{
return BadRequest(new { error = "category must be between 0 and 3." });
}
var recipes = await _recipeService.GetRecipesAsync(category, search, ct);
return Ok(recipes);
}
/// <summary>Returns category metadata with active recipe counts.</summary>
[HttpGet("categories")]
[ProducesResponseType(typeof(IReadOnlyList<CategoryCountDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCategories(CancellationToken ct)
{
var categories = await _recipeService.GetCategoriesAsync(ct);
return Ok(categories);
}
/// <summary>Returns a single recipe with ingredients and ordered steps.</summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRecipe(int id, CancellationToken ct)
{
var recipe = await _recipeService.GetRecipeByIdAsync(id, ct);
return recipe is null
? NotFound(new { error = $"Recipe {id} was not found." })
: Ok(recipe);
}
}

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace MealPlan.Api.DTOs.Auth;
/// <summary>Credentials submitted to <c>POST /api/auth/login</c>.</summary>
public class LoginRequestDto
{
[Required]
[EmailAddress]
[MaxLength(256)]
public string Email { get; set; } = string.Empty;
[Required]
[MaxLength(256)]
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace MealPlan.Api.DTOs.Auth;
/// <summary>Body for <c>POST /api/auth/refresh</c> and <c>POST /api/auth/logout</c>.</summary>
public class RefreshRequestDto
{
[Required]
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace MealPlan.Api.DTOs.Auth;
/// <summary>Payload for <c>POST /api/auth/register</c>.</summary>
public class RegisterRequestDto
{
[Required]
[EmailAddress]
[MaxLength(256)]
public string Email { get; set; } = string.Empty;
[MaxLength(256)]
public string? UserName { get; set; }
[Required]
[MinLength(8)]
[MaxLength(256)]
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,13 @@
namespace MealPlan.Api.DTOs.Auth;
/// <summary>Token pair returned by login and refresh.</summary>
public class TokenResponseDto
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
/// <summary>Access-token lifetime in seconds (clients use it to schedule silent refresh).</summary>
public int ExpiresInSeconds { get; set; }
public string TokenType { get; set; } = "Bearer";
}

View File

@@ -0,0 +1,9 @@
namespace MealPlan.Api.DTOs.Auth;
/// <summary>Current-user payload returned by <c>GET /api/auth/me</c>.</summary>
public class UserInfoDto
{
public string Id { get; set; } = string.Empty;
public string? UserName { get; set; }
public string? Email { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace MealPlan.Api.DTOs.Recipe;
/// <summary>Category metadata with the number of active recipes it contains.</summary>
public class CategoryCountDto
{
public int Category { get; set; }
public string Name { get; set; } = string.Empty;
public int Count { get; set; }
}

View File

@@ -0,0 +1,33 @@
namespace MealPlan.Api.DTOs.Recipe;
/// <summary>Full recipe detail including ingredients and ordered preparation steps.</summary>
public class RecipeDetailDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int MealCategory { get; set; }
public int? Calories { get; set; }
public decimal? Protein { get; set; }
public decimal? Fat { get; set; }
public decimal? Carbs { get; set; }
public int? PrepTimeMinutes { get; set; }
public List<IngredientDto> Ingredients { get; set; } = new();
public List<RecipeStepDto> Steps { get; set; } = new();
}
public class IngredientDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal? AmountGrams { get; set; }
public string? Unit { get; set; }
public int SortOrder { get; set; }
}
public class RecipeStepDto
{
public int Id { get; set; }
public int StepNumber { get; set; }
public string Description { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,11 @@
namespace MealPlan.Api.DTOs.Recipe;
/// <summary>Lightweight recipe projection used by list/grid views.</summary>
public class RecipeListItemDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int MealCategory { get; set; }
public int? Calories { get; set; }
public int? PrepTimeMinutes { get; set; }
}

View File

@@ -0,0 +1,65 @@
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Data;
/// <summary>
/// Database-first context for the existing PostgreSQL database.
/// Tables are mapped explicitly; this context never creates or alters schema.
/// </summary>
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Recipe> Recipes => Set<Recipe>();
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
public DbSet<RecipeStep> RecipeSteps => Set<RecipeStep>();
public DbSet<ApplicationUser> Users => Set<ApplicationUser>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema("diet");
modelBuilder.Entity<Recipe>(e =>
{
e.HasKey(r => r.Id);
e.Property(r => r.Id).UseIdentityByDefaultColumn();
e.Property(r => r.Protein).HasColumnType("numeric(6,2)");
e.Property(r => r.Fat).HasColumnType("numeric(6,2)");
e.Property(r => r.Carbs).HasColumnType("numeric(6,2)");
e.HasMany(r => r.Ingredients)
.WithOne(i => i.Recipe!)
.HasForeignKey(i => i.RecipeId)
.OnDelete(DeleteBehavior.Cascade);
e.HasMany(r => r.Steps)
.WithOne(s => s.Recipe!)
.HasForeignKey(s => s.RecipeId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Ingredient>(e =>
{
e.HasKey(i => i.Id);
e.Property(i => i.Id).UseIdentityByDefaultColumn();
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
});
modelBuilder.Entity<RecipeStep>(e =>
{
e.HasKey(s => s.Id);
e.Property(s => s.Id).UseIdentityByDefaultColumn();
});
modelBuilder.Entity<ApplicationUser>(e =>
{
e.HasKey(u => u.Id);
e.Property(u => u.Id).HasColumnType("uuid");
});
}
}

27
MealPlan.Api/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# ---------- Build stage ----------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore first to leverage Docker layer caching.
COPY ["MealPlan.Api.csproj", "./"]
RUN dotnet restore "MealPlan.Api.csproj"
COPY . .
RUN dotnet publish "MealPlan.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
# ---------- Runtime stage ----------
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Run as the non-root user shipped in the aspnet image.
USER $APP_UID
ENV ASPNETCORE_URLS=http://+:5000 \
ASPNETCORE_ENVIRONMENT=Production \
DOTNET_RUNNING_IN_CONTAINER=true
EXPOSE 5000
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MealPlan.Api.dll"]

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>MealPlan.Api</RootNamespace>
<AssemblyName>MealPlan.Api</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,62 @@
using System.Text.Json;
namespace MealPlan.Api.Middleware;
/// <summary>
/// Converts unhandled exceptions into RFC-7807-style JSON problem responses and logs them.
/// Internal details are never leaked to the client.
/// </summary>
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Client disconnected; nothing actionable to report.
_logger.LogDebug("Request {Path} was cancelled by the client", context.Request.Path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
if (context.Response.HasStarted)
{
throw;
}
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
var payload = new
{
type = "https://httpstatuses.io/500",
title = "An unexpected error occurred.",
status = StatusCodes.Status500InternalServerError,
traceId = context.TraceIdentifier,
};
await context.Response.WriteAsync(JsonSerializer.Serialize(payload));
}
}
}
public static class ExceptionMiddlewareExtensions
{
public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder app) =>
app.UseMiddleware<ExceptionMiddleware>();
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
/// <summary>
/// Maps to the existing ASP.NET Core Identity "AspNetUsers" table.
/// Only the columns described in the schema are mapped. Password verification uses
/// ASP.NET Core Identity's <see cref="Microsoft.AspNetCore.Identity.IPasswordHasher{TUser}"/>,
/// so no additional Identity tables are required for this read-only login flow.
/// </summary>
[Table("AspNetUsers")]
public class ApplicationUser
{
[Column("Id")]
public Guid Id { get; set; }
[Column("UserName")]
public string? UserName { get; set; }
[Column("Email")]
public string? Email { get; set; }
[Column("PasswordHash")]
public string? PasswordHash { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("IsActive")]
public bool IsActive { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
/// <summary>
/// Maps to the existing "Ingredients" table.
/// </summary>
[Table("Ingredients")]
public class Ingredient
{
[Column("Id")]
public int Id { get; set; }
[Column("RecipeId")]
public int RecipeId { get; set; }
[Column("Name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Column("AmountGrams")]
public decimal? AmountGrams { get; set; }
[Column("Unit")]
[MaxLength(50)]
public string? Unit { get; set; }
[Column("SortOrder")]
public int SortOrder { get; set; }
public Recipe? Recipe { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace MealPlan.Api.Models;
/// <summary>
/// Meal categories. Integer values map directly to the "MealCategory" column on "Recipes".
/// </summary>
public enum MealCategory
{
Breakfast = 0,
SecondBreakfast = 1,
Lunch = 2,
Dinner = 3
}

View File

@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
/// <summary>
/// Maps to the existing "Recipes" table. Database-first; no migrations manage this table.
/// </summary>
[Table("Recipes")]
public class Recipe
{
[Column("Id")]
public int Id { get; set; }
[Column("Name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
/// <summary>Stored as INT in the DB (0..3). Mapped to the <see cref="MealCategory"/> enum.</summary>
[Column("MealCategory")]
public int MealCategory { get; set; }
[Column("Calories")]
public int? Calories { get; set; }
[Column("Protein")]
public decimal? Protein { get; set; }
[Column("Fat")]
public decimal? Fat { get; set; }
[Column("Carbs")]
public decimal? Carbs { get; set; }
[Column("PrepTimeMinutes")]
public int? PrepTimeMinutes { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
[Column("IsActive")]
public bool IsActive { get; set; }
public ICollection<Ingredient> Ingredients { get; set; } = new List<Ingredient>();
public ICollection<RecipeStep> Steps { get; set; } = new List<RecipeStep>();
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
/// <summary>
/// Maps to the existing "RecipeSteps" table.
/// </summary>
[Table("RecipeSteps")]
public class RecipeStep
{
[Column("Id")]
public int Id { get; set; }
[Column("RecipeId")]
public int RecipeId { get; set; }
[Column("StepNumber")]
public int StepNumber { get; set; }
[Column("Description")]
public string Description { get; set; } = string.Empty;
public Recipe? Recipe { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace MealPlan.Api.Models;
/// <summary>
/// In-memory refresh token record. Stored in a singleton store rather than the database,
/// because the existing schema must not be modified and no migrations may be created.
/// Rotation: every successful refresh invalidates the previous token and issues a new one.
/// </summary>
public class RefreshToken
{
public string Token { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public DateTime ExpiresAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; }
public bool IsRevoked { get; set; }
public bool IsActive => !IsRevoked && DateTime.UtcNow < ExpiresAtUtc;
}

231
MealPlan.Api/Program.cs Normal file
View File

@@ -0,0 +1,231 @@
using System.Text;
using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using MealPlan.Api.Data;
using MealPlan.Api.Middleware;
using MealPlan.Api.Models;
using MealPlan.Api.Services;
using MealPlan.Api.Validators;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Optional local overrides (connection string, secrets) — not committed; see appsettings.Local.json.example.
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
// ---------------------------------------------------------------------------
// Serilog: structured logging to console + rolling daily file.
// ---------------------------------------------------------------------------
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
path: "logs/mealplan-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
shared: true));
// ---------------------------------------------------------------------------
// Configuration / options
// ---------------------------------------------------------------------------
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException(
"ConnectionStrings__DefaultConnection is not configured.");
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Jwt configuration section is missing.");
if (string.IsNullOrWhiteSpace(jwtOptions.Secret) || jwtOptions.Secret.Length < 32)
{
throw new InvalidOperationException(
"Jwt__Secret must be configured and at least 32 characters long.");
}
var allowedOrigin = builder.Configuration["CORS:AllowedOrigin"];
// ---------------------------------------------------------------------------
// EF Core (PostgreSQL, database-first — no migrations)
// ---------------------------------------------------------------------------
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure()));
// ---------------------------------------------------------------------------
// Application services
// ---------------------------------------------------------------------------
builder.Services.AddSingleton<IRefreshTokenStore, InMemoryRefreshTokenStore>();
builder.Services.AddHostedService<RefreshTokenCleanupService>();
builder.Services.AddSingleton<IPasswordHasher<ApplicationUser>, PasswordHasher<ApplicationUser>>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRecipeService, RecipeService>();
// ---------------------------------------------------------------------------
// Authentication / authorization (JWT bearer)
// ---------------------------------------------------------------------------
builder.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
ClockSkew = TimeSpan.Zero,
};
});
builder.Services.AddAuthorization();
// ---------------------------------------------------------------------------
// Rate limiting: max 5 login attempts per minute per client IP.
// ---------------------------------------------------------------------------
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
});
});
});
// ---------------------------------------------------------------------------
// CORS: restricted to the configured frontend origin only.
// ---------------------------------------------------------------------------
const string corsPolicy = "frontend";
builder.Services.AddCors(options =>
{
options.AddPolicy(corsPolicy, policy =>
{
if (builder.Environment.IsDevelopment())
{
// Vite may use 5173, 5174, 5175, etc. when ports are busy.
policy.SetIsOriginAllowed(origin =>
!string.IsNullOrEmpty(origin) &&
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
(uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase)))
.AllowAnyHeader()
.AllowAnyMethod();
}
else if (!string.IsNullOrWhiteSpace(allowedOrigin))
{
policy.WithOrigins(allowedOrigin)
.AllowAnyHeader()
.AllowAnyMethod();
}
});
});
// Forwarded headers so the app honors the original scheme/IP behind the nginx proxy.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddControllers();
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<LoginRequestValidator>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Kestrel listens on 5000 (HTTP) inside the container; TLS is terminated at nginx.
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
var app = builder.Build();
// ---------------------------------------------------------------------------
// HTTP request pipeline
// ---------------------------------------------------------------------------
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionMiddleware();
// Security headers on every response.
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["X-Frame-Options"] = "DENY";
headers["X-Content-Type-Options"] = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
headers["Content-Security-Policy"] =
"default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'";
headers["X-Permitted-Cross-Domain-Policies"] = "none";
await next();
});
// Behind nginx, X-Forwarded-Proto makes HTTPS redirection work for the public URL.
app.UseHttpsRedirection();
if (app.Environment.IsProduction())
{
app.UseHsts();
}
else
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(corsPolicy);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
try
{
Log.Information("Starting MealPlan API");
if (app.Environment.IsDevelopment())
{
var authEndpoints = app.Services.GetRequiredService<Microsoft.AspNetCore.Routing.EndpointDataSource>()
.Endpoints
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
.Where(e => e.RoutePattern.RawText?.Contains("auth", StringComparison.OrdinalIgnoreCase) == true)
.Select(e => e.RoutePattern.RawText)
.Distinct()
.OrderBy(x => x);
Log.Information("Auth endpoints: {Endpoints}", string.Join(", ", authEndpoints));
}
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "MealPlan API terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,17 @@
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Services;
/// <summary>Outcome of an auth operation. Avoids throwing for expected failures (bad credentials, etc.).</summary>
public class AuthResult
{
public bool Succeeded { get; private init; }
public string? Error { get; private init; }
public TokenResponseDto? Tokens { get; private init; }
public static AuthResult Success(TokenResponseDto tokens) =>
new() { Succeeded = true, Tokens = tokens };
public static AuthResult Fail(string error) =>
new() { Succeeded = false, Error = error };
}

View File

@@ -0,0 +1,172 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Auth;
using MealPlan.Api.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>
/// Read-only authentication against the existing "AspNetUsers" table.
/// Password verification uses ASP.NET Core Identity's <see cref="IPasswordHasher{TUser}"/> so
/// it is compatible with hashes produced by ASP.NET Core Identity.
/// </summary>
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly ITokenService _tokenService;
private readonly IRefreshTokenStore _refreshTokenStore;
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
private readonly ILogger<AuthService> _logger;
public AuthService(
AppDbContext db,
ITokenService tokenService,
IRefreshTokenStore refreshTokenStore,
IPasswordHasher<ApplicationUser> passwordHasher,
ILogger<AuthService> logger)
{
_db = db;
_tokenService = tokenService;
_refreshTokenStore = refreshTokenStore;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default)
{
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
var emailTaken = await _db.Users
.AsNoTracking()
.AnyAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail, ct);
if (emailTaken)
{
return AuthResult.Fail("An account with this email already exists.");
}
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = request.Email.Trim(),
UserName = string.IsNullOrWhiteSpace(request.UserName)
? request.Email.Trim()
: request.UserName.Trim(),
CreatedAt = DateTime.UtcNow,
IsActive = true,
};
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
_db.Users.Add(user);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("User {UserId} registered with email {Email}", user.Id, user.Email);
return AuthResult.Success(IssueTokens(user));
}
public async Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default)
{
var normalizedEmail = request.Email.Trim();
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail.ToLower(), ct);
// Uniform failure message to avoid user enumeration.
const string invalidCredentials = "Invalid email or password.";
if (user is null || string.IsNullOrEmpty(user.PasswordHash))
{
_logger.LogWarning("Login failed: user not found for {Email}", normalizedEmail);
return AuthResult.Fail(invalidCredentials);
}
if (!user.IsActive)
{
_logger.LogWarning("Login failed: inactive account {UserId}", user.Id);
return AuthResult.Fail("This account is disabled.");
}
var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (verification == PasswordVerificationResult.Failed)
{
_logger.LogWarning("Login failed: bad password for {UserId}", user.Id);
return AuthResult.Fail(invalidCredentials);
}
_logger.LogInformation("User {UserId} logged in", user.Id);
return AuthResult.Success(IssueTokens(user));
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default)
{
var existing = _refreshTokenStore.Get(refreshToken);
if (existing is null || !existing.IsActive)
{
_logger.LogWarning("Refresh failed: token missing or inactive");
return AuthResult.Fail("Invalid or expired refresh token.");
}
// Rotation: immediately invalidate the presented token.
_refreshTokenStore.Revoke(refreshToken);
if (!Guid.TryParse(existing.UserId, out var refreshUserId))
{
return AuthResult.Fail("Invalid or expired refresh token.");
}
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == refreshUserId, ct);
if (user is null || !user.IsActive)
{
_logger.LogWarning("Refresh failed: user {UserId} missing or inactive", existing.UserId);
return AuthResult.Fail("Invalid or expired refresh token.");
}
return AuthResult.Success(IssueTokens(user));
}
public void Logout(string refreshToken) => _refreshTokenStore.Revoke(refreshToken);
public async Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default)
{
if (!Guid.TryParse(userId, out var parsedUserId))
{
return null;
}
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == parsedUserId, ct);
if (user is null)
{
return null;
}
return new UserInfoDto
{
Id = user.Id.ToString(),
UserName = user.UserName,
Email = user.Email,
};
}
private TokenResponseDto IssueTokens(ApplicationUser user)
{
var accessToken = _tokenService.CreateAccessToken(user);
var refreshToken = _tokenService.CreateRefreshToken(user.Id.ToString());
return new TokenResponseDto
{
AccessToken = accessToken,
RefreshToken = refreshToken.Token,
ExpiresInSeconds = _tokenService.AccessTokenLifetimeSeconds,
TokenType = "Bearer",
};
}
}

View File

@@ -0,0 +1,18 @@
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Services;
public interface IAuthService
{
Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default);
Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default);
/// <summary>Validates and rotates a refresh token, issuing a fresh token pair.</summary>
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default);
/// <summary>Revokes the supplied refresh token. Idempotent.</summary>
void Logout(string refreshToken);
Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,12 @@
using MealPlan.Api.DTOs.Recipe;
namespace MealPlan.Api.Services;
public interface IRecipeService
{
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, CancellationToken ct = default);
Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<CategoryCountDto>> GetCategoriesAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,20 @@
using MealPlan.Api.Models;
namespace MealPlan.Api.Services;
/// <summary>
/// Stores issued refresh tokens. Backed by an in-memory implementation because the
/// existing database schema must not be modified and no migrations may be added.
/// </summary>
public interface IRefreshTokenStore
{
void Add(RefreshToken token);
RefreshToken? Get(string token);
/// <summary>Marks a token revoked. Returns the revoked token if it existed and was active.</summary>
RefreshToken? Revoke(string token);
/// <summary>Removes expired/revoked tokens to bound memory usage.</summary>
void RemoveExpired();
}

View File

@@ -0,0 +1,16 @@
using System.Security.Claims;
using MealPlan.Api.Models;
namespace MealPlan.Api.Services;
public interface ITokenService
{
/// <summary>Creates a signed JWT access token for the given user.</summary>
string CreateAccessToken(ApplicationUser user);
/// <summary>Generates and persists a new refresh token for the given user id.</summary>
RefreshToken CreateRefreshToken(string userId);
/// <summary>Access-token lifetime in seconds (exposed to clients for scheduling refresh).</summary>
int AccessTokenLifetimeSeconds { get; }
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Concurrent;
using MealPlan.Api.Models;
namespace MealPlan.Api.Services;
/// <summary>
/// Thread-safe in-memory refresh token store registered as a singleton.
/// Note: tokens live only for the lifetime of the process. For multi-instance
/// deployments this should be swapped for a shared store (e.g. Redis), but it
/// satisfies the "do not modify the database" constraint of this project.
/// </summary>
public class InMemoryRefreshTokenStore : IRefreshTokenStore
{
private readonly ConcurrentDictionary<string, RefreshToken> _tokens = new();
public void Add(RefreshToken token) => _tokens[token.Token] = token;
public RefreshToken? Get(string token) =>
_tokens.TryGetValue(token, out var found) ? found : null;
public RefreshToken? Revoke(string token)
{
if (!_tokens.TryGetValue(token, out var found))
{
return null;
}
var wasActive = found.IsActive;
found.IsRevoked = true;
return wasActive ? found : null;
}
public void RemoveExpired()
{
foreach (var kvp in _tokens)
{
if (kvp.Value.IsRevoked || DateTime.UtcNow >= kvp.Value.ExpiresAtUtc)
{
_tokens.TryRemove(kvp.Key, out _);
}
}
}
}

View File

@@ -0,0 +1,17 @@
namespace MealPlan.Api.Services;
/// <summary>Strongly-typed JWT settings bound from the "Jwt" configuration section / env vars.</summary>
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Secret { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
/// <summary>Access-token lifetime in minutes. Defaults to 15 per the security spec.</summary>
public int AccessTokenMinutes { get; set; } = 15;
/// <summary>Refresh-token lifetime in days. Defaults to 7 per the security spec.</summary>
public int RefreshTokenDays { get; set; } = 7;
}

View File

@@ -0,0 +1,107 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>
/// Read-only recipe queries. All filtering is done with EF Core LINQ (parameterized);
/// no raw SQL is used anywhere.
/// </summary>
public class RecipeService : IRecipeService
{
private readonly AppDbContext _db;
public RecipeService(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(
int? category, string? search, CancellationToken ct = default)
{
var query = _db.Recipes.AsNoTracking().Where(r => r.IsActive);
if (category is >= 0 and <= 3)
{
query = query.Where(r => r.MealCategory == category);
}
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
query = query.Where(r => EF.Functions.ILike(r.Name, $"%{term}%"));
}
return await query
.OrderBy(r => r.Name)
.Select(r => new RecipeListItemDto
{
Id = r.Id,
Name = r.Name,
MealCategory = r.MealCategory,
Calories = r.Calories,
PrepTimeMinutes = r.PrepTimeMinutes,
})
.ToListAsync(ct);
}
public async Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default)
{
return await _db.Recipes
.AsNoTracking()
.Where(r => r.Id == id && r.IsActive)
.Select(r => new RecipeDetailDto
{
Id = r.Id,
Name = r.Name,
MealCategory = r.MealCategory,
Calories = r.Calories,
Protein = r.Protein,
Fat = r.Fat,
Carbs = r.Carbs,
PrepTimeMinutes = r.PrepTimeMinutes,
Ingredients = r.Ingredients
.OrderBy(i => i.SortOrder)
.ThenBy(i => i.Id)
.Select(i => new IngredientDto
{
Id = i.Id,
Name = i.Name,
AmountGrams = i.AmountGrams,
Unit = i.Unit,
SortOrder = i.SortOrder,
})
.ToList(),
Steps = r.Steps
.OrderBy(s => s.StepNumber)
.Select(s => new RecipeStepDto
{
Id = s.Id,
StepNumber = s.StepNumber,
Description = s.Description,
})
.ToList(),
})
.FirstOrDefaultAsync(ct);
}
public async Task<IReadOnlyList<CategoryCountDto>> GetCategoriesAsync(CancellationToken ct = default)
{
var counts = await _db.Recipes
.AsNoTracking()
.Where(r => r.IsActive)
.GroupBy(r => r.MealCategory)
.Select(g => new { Category = g.Key, Count = g.Count() })
.ToListAsync(ct);
// Always return all four categories, even when a category has zero recipes.
return Enum.GetValues<MealCategory>()
.Select(category => new CategoryCountDto
{
Category = (int)category,
Name = category.ToString(),
Count = counts.FirstOrDefault(c => c.Category == (int)category)?.Count ?? 0,
})
.OrderBy(c => c.Category)
.ToList();
}
}

View File

@@ -0,0 +1,35 @@
namespace MealPlan.Api.Services;
/// <summary>
/// Periodically purges expired and revoked refresh tokens from the in-memory store.
/// </summary>
public class RefreshTokenCleanupService : BackgroundService
{
private readonly IRefreshTokenStore _store;
private readonly ILogger<RefreshTokenCleanupService> _logger;
public RefreshTokenCleanupService(
IRefreshTokenStore store,
ILogger<RefreshTokenCleanupService> logger)
{
_store = store;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
_store.RemoveExpired();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Refresh token cleanup failed");
}
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}

View File

@@ -0,0 +1,82 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using MealPlan.Api.Models;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace MealPlan.Api.Services;
/// <summary>
/// Issues JWT access tokens and rotating refresh tokens.
/// Refresh tokens are cryptographically random opaque strings tracked in <see cref="IRefreshTokenStore"/>.
/// </summary>
public class TokenService : ITokenService
{
private readonly JwtOptions _options;
private readonly IRefreshTokenStore _refreshTokenStore;
public TokenService(IOptions<JwtOptions> options, IRefreshTokenStore refreshTokenStore)
{
_options = options.Value;
_refreshTokenStore = refreshTokenStore;
}
public int AccessTokenLifetimeSeconds => _options.AccessTokenMinutes * 60;
public string CreateAccessToken(ApplicationUser user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var userId = user.Id.ToString();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(ClaimTypes.NameIdentifier, userId),
};
if (!string.IsNullOrWhiteSpace(user.Email))
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
}
if (!string.IsNullOrWhiteSpace(user.UserName))
{
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
}
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public RefreshToken CreateRefreshToken(string userId)
{
var token = new RefreshToken
{
Token = GenerateSecureToken(),
UserId = userId,
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddDays(_options.RefreshTokenDays),
IsRevoked = false,
};
_refreshTokenStore.Add(token);
return token;
}
private static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes);
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Validators;
public class LoginRequestValidator : AbstractValidator<LoginRequestDto>
{
public LoginRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MaximumLength(256);
RuleFor(x => x.Password)
.NotEmpty()
.MaximumLength(256);
}
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Validators;
public class RefreshRequestValidator : AbstractValidator<RefreshRequestDto>
{
public RefreshRequestValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty()
.MaximumLength(512);
}
}

View File

@@ -0,0 +1,27 @@
using FluentValidation;
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Validators;
public class RegisterRequestValidator : AbstractValidator<RegisterRequestDto>
{
public RegisterRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MaximumLength(256);
RuleFor(x => x.UserName)
.MaximumLength(256)
.When(x => !string.IsNullOrWhiteSpace(x.UserName));
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.MaximumLength(256)
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
.Matches(@"[a-z]").WithMessage("Password must contain at least one lowercase letter.")
.Matches(@"[0-9]").WithMessage("Password must contain at least one digit.");
}
}

View File

@@ -0,0 +1,19 @@
{
"Jwt": {
"Secret": "dev-only-insecure-secret-change-me-please-32+chars",
"Issuer": "DailyMeals",
"Audience": "DailyMeals"
},
"CORS": {
"AllowedOrigin": "http://localhost:5173"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore": "Information"
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=your-db-host;Port=5432;Database=mealplan;Username=user;Password=secret"
}
}

View File

@@ -0,0 +1,25 @@
{
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": {
"Secret": "",
"Issuer": "DailyMeals",
"Audience": "DailyMeals",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 7
},
"CORS": {
"AllowedOrigin": ""
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
},
"AllowedHosts": "*"
}

248
README.md Normal file
View File

@@ -0,0 +1,248 @@
# DailyMeals
A full-stack meal-plan web application. Browse recipes by meal category, view full
recipes with macros, ingredients and preparation steps, and export a selection of
recipes to a PDF that includes an aggregated shopping list.
- **Frontend:** React 18 + TypeScript, Vite, React Router v6, Tailwind CSS v3,
TanStack Query v5, React Hook Form + Zod, jsPDF + html2canvas, Lucide icons, Axios.
- **Backend:** ASP.NET Core 8 Web API, EF Core 8 (Npgsql), JWT auth with refresh-token
rotation, rate limiting, Serilog, full security hardening.
- **Database:** an **existing, externally hosted PostgreSQL 16** database. The app only
connects to it — it never creates, seeds, or migrates the schema.
- **Infrastructure:** Docker Compose runs the API and the frontend only (no database
container). nginx serves the SPA, terminates TLS, and reverse-proxies `/api`.
---
## Architecture
```
Browser ──HTTPS──> nginx (frontend container) ──> static SPA
└── /api ──HTTP──> ASP.NET Core API (api container) ──> external PostgreSQL
```
- nginx redirects HTTP → HTTPS, sets security headers, gzips responses and proxies
`/api` to the API container.
- The API validates JWTs, enforces login rate limiting, and talks to PostgreSQL via
EF Core (parameterized queries only).
---
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- Network access from the API container to your external PostgreSQL server
- For local development without Docker: [.NET 8 SDK](https://dotnet.microsoft.com/) and
[Node.js 20+](https://nodejs.org/)
---
## Production deployment (Linux server)
See **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** for a full guide (Docker, firewall, PostgreSQL access, Lets Encrypt HTTPS).
Quick version:
```bash
cp .env.example .env && nano .env
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
---
## Quick start (Docker)
1. **Copy the environment template and fill in your values:**
```bash
cp .env.example .env
# then edit .env
```
Required variables (see `.env.example` for descriptions):
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------ |
| `DB_CONNECTION_STRING` | Npgsql connection string to the existing PostgreSQL database |
| `JWT_SECRET` | Signing key for JWTs (≥ 32 chars; `openssl rand -base64 48`) |
| `JWT_ISSUER` | JWT issuer identifier (e.g. `DailyMeals`) |
| `JWT_AUDIENCE` | JWT audience identifier (e.g. `DailyMeals`) |
| `CORS_ALLOWED_ORIGIN` | Public origin of the frontend (e.g. `https://localhost`) |
2. **Start the stack:**
```bash
docker compose up -d --build
```
3. **Open the application:**
- <http://localhost> — automatically redirects to **<https://localhost>**
> The frontend container ships with a **self-signed** TLS certificate so HTTPS works
> out of the box, so your browser will warn about the certificate on first visit.
> For production, replace it with a real certificate (see below).
Sign in with a user that already exists in the `AspNetUsers` table of your database.
Passwords are verified with ASP.NET Core Identity's password hasher, so the app is
compatible with hashes produced by ASP.NET Core Identity.
---
## JetBrains Rider
1. Open **`DailyMeals.sln`** in Rider (open the **solution root**, not the `meal-plan-frontend` folder alone).
2. Copy local API settings (connection string is not committed):
```bash
cp MealPlan.Api/appsettings.Local.json.example MealPlan.Api/appsettings.Local.json
# Edit appsettings.Local.json with your PostgreSQL connection string
```
3. Install Node + frontend dependencies (no Homebrew `npm` required):
```bash
cd DailyMeals # repository root (where DailyMeals.sln lives)
./scripts/install-node.sh
cd meal-plan-frontend
../scripts/npm install
```
4. In Rider, open the run configuration dropdown (top toolbar). You should see:
- **DailyMeals (API + Frontend)** — API (`http://localhost:5000`) + Vite (`http://localhost:5173`) (default).
- **MealPlan.Api (http)** — API only (Swagger at `/swagger`).
- **meal-plan-frontend (vite dev)** — frontend only.
If configs are missing: **File → Reload All from Disk**, or close Rider and reopen `DailyMeals.sln`.
5. Node is provided by **`.toolchain/`** (same approach as FA_WEB). Rider run configs point at that interpreter automatically.
---
## Local development (without Docker)
**Backend:**
```bash
cd MealPlan.Api
export ConnectionStrings__DefaultConnection="Host=...;Port=5432;Database=...;Username=...;Password=..."
export Jwt__Secret="dev-secret-at-least-32-characters-long-xxxxx"
dotnet run
# API on http://localhost:5000 (Swagger UI at /swagger in Development)
```
**Frontend:**
```bash
cd meal-plan-frontend
npm install
npm run dev
# App on http://localhost:5173 — /api is proxied to http://localhost:5000
```
---
## API reference
All `/api/recipes` endpoints require a valid `Authorization: Bearer <accessToken>` header.
| Method | Endpoint | Description |
| ------ | ------------------------- | ----------------------------------------------------- |
| POST | `/api/auth/register` | Create account → access + refresh token (rate-limited) |
| POST | `/api/auth/login` | Login → access + refresh token (rate-limited 5/min/IP)|
| POST | `/api/auth/refresh` | Rotate refresh token → new token pair |
| POST | `/api/auth/logout` | Revoke a refresh token |
| GET | `/api/auth/me` | Current user info |
| GET | `/api/recipes` | List recipes — `?category=0..3` and `?search=` filters|
| GET | `/api/recipes/{id}` | Full recipe detail (ingredients + steps) |
| GET | `/api/recipes/categories` | Categories with active recipe counts |
Meal categories: `0 = Breakfast`, `1 = SecondBreakfast`, `2 = Lunch`, `3 = Dinner`.
---
## Security
- **JWT** access tokens expire after **15 minutes**; refresh tokens after **7 days**.
- **Refresh-token rotation:** every refresh invalidates the presented token and issues a
new one. (Refresh tokens are tracked in memory to honor the "do not modify the database"
constraint — for multi-instance deployments, swap `InMemoryRefreshTokenStore` for a
shared store such as Redis.)
- **Rate limiting:** `POST /api/auth/login` is limited to 5 attempts per minute per IP.
- **CORS** is restricted to `CORS__AllowedOrigin`.
- **Security headers** are set both in the API and at the nginx edge:
`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy: no-referrer`, `Content-Security-Policy`, and HSTS.
- **HTTPS** is enforced: nginx redirects all HTTP traffic to HTTPS.
- **No raw SQL** — all data access uses EF Core parameterized queries.
- **All secrets** are provided via environment variables; nothing is hardcoded.
- **Structured logging** with Serilog to console and rolling daily files
(`MealPlan.Api/logs/`).
---
## PDF export
On the **All Meals** page, select one or more recipes and click **Generate PDF**. The PDF
contains:
1. **One page per recipe** — name, category badge, calories + macros, prep time,
ingredients and numbered steps.
2. **A final shopping-list page** — ingredients aggregated across all selected recipes:
- gram amounts of the same ingredient are summed into one line;
- non-gram units (pcs, tbsp, …) are summed separately per unit;
- "to taste" / "for serving" / "optional" items are listed once without a quantity;
- a parenthetical breakdown shows the per-recipe contributions when an ingredient
appears in more than one recipe;
- a footer lists every recipe included in the export with its category and calories.
---
## Using a real TLS certificate (production)
The frontend image generates a self-signed certificate at build time. To use a real
certificate (e.g. from Let's Encrypt), mount your cert/key over the defaults in
`docker-compose.yml`:
```yaml
frontend:
volumes:
- /path/to/fullchain.pem:/etc/nginx/certs/server.crt:ro
- /path/to/privkey.pem:/etc/nginx/certs/server.key:ro
```
---
## Project layout
```
DailyMeals/
├── MealPlan.Api/ ASP.NET Core 8 Web API
│ ├── Controllers/ AuthController, RecipesController
│ ├── Data/ AppDbContext (database-first, no migrations)
│ ├── Models/ Recipe, Ingredient, RecipeStep, ApplicationUser, ...
│ ├── DTOs/ Auth + Recipe DTOs
│ ├── Services/ AuthService, RecipeService, TokenService, token store
│ ├── Middleware/ ExceptionMiddleware
│ ├── Program.cs Composition root / pipeline
│ └── Dockerfile
├── meal-plan-frontend/ React + TypeScript SPA
│ ├── src/ api, components, pages, hooks, store, types, utils
│ ├── nginx.conf TLS, gzip, security headers, /api proxy, SPA fallback
│ └── Dockerfile
├── docker-compose.yml API + frontend (no database container)
└── .env.example
```
---
## Notes
- Designed for **Linux hosting** — no Windows-specific APIs or paths.
- **Dark mode** follows `prefers-color-scheme` by default and can be toggled manually;
the preference is kept in memory only (no `localStorage`), so it resets on reload.
- Because auth tokens are held in memory only, a full page reload returns you to the
login screen — this is intentional given the in-memory storage requirement.
```

12
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,12 @@
# Production overlay for Linux server deployment.
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# With Let's Encrypt certificates on the host, also add:
# -f docker-compose.tls.yml
# (copy docker-compose.tls.example.yml and edit paths)
services:
api:
# Do not publish the API port publicly; nginx is the only entry point.
ports: !reset []

View File

@@ -0,0 +1,9 @@
# Copy to docker-compose.tls.yml and set your certificate paths.
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
services:
frontend:
volumes:
- /etc/letsencrypt/live/meals.example.com/fullchain.pem:/etc/nginx/certs/server.crt:ro
- /etc/letsencrypt/live/meals.example.com/privkey.pem:/etc/nginx/certs/server.key:ro

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
version: "3.9"
services:
api:
build:
context: ./MealPlan.Api
dockerfile: Dockerfile
environment:
ConnectionStrings__DefaultConnection: ${DB_CONNECTION_STRING}
Jwt__Secret: ${JWT_SECRET}
Jwt__Issuer: ${JWT_ISSUER}
Jwt__Audience: ${JWT_AUDIENCE}
CORS__AllowedOrigin: ${CORS_ALLOWED_ORIGIN}
ASPNETCORE_ENVIRONMENT: Production
expose:
- "5000"
ports:
- "5000:5000"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
frontend:
build:
context: ./meal-plan-frontend
dockerfile: Dockerfile
ports:
- "80:80"
- "443:443"
depends_on:
- api
restart: unless-stopped

245
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,245 @@
# Deploying DailyMeals on a Linux server
This guide assumes a **VPS or dedicated Linux host** (Ubuntu 22.04/24.04 or Debian 12) with:
- Docker Engine + Docker Compose plugin
- An **external PostgreSQL** database (already populated — no DB container in this stack)
- A domain name pointing at the server (recommended for HTTPS)
## Architecture on the server
```
Internet
[ports 80 / 443] nginx (frontend container) ── SPA static files
└── /api ──► ASP.NET Core API (api container, internal :5000)
External PostgreSQL (your DB host)
```
Only **80** and **443** need to be public. The API stays on the Docker network.
---
## 1. Prepare the server
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker (official convenience script) + Compose plugin
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
# Log out and back in so the docker group applies
docker --version
docker compose version
```
Optional firewall (UFW):
```bash
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
---
## 2. Allow the API to reach PostgreSQL
On your **database server** (or cloud DB console):
1. Ensure PostgreSQL listens on an address reachable from the app server.
2. Add the **app servers public IP** to `pg_hba.conf` (or the providers “allowed IPs” list).
3. Open port **5432** only to that IP (security group / firewall).
Test from the app server (requires `postgresql-client`):
```bash
psql "host=YOUR_DB_HOST port=5432 dbname=YOUR_DB user=YOUR_USER password=YOUR_PASS" -c "SELECT 1"
```
---
## 3. Deploy the application
```bash
# Clone (or upload) the project
git clone <your-repo-url> dailymeals
cd dailymeals
# Configure secrets
cp .env.example .env
nano .env # or vim
```
### Required `.env` values (production example)
```env
DB_CONNECTION_STRING=Host=10.0.0.5;Port=5432;Database=mealplan;Username=mealapp;Password=STRONG_DB_PASSWORD
JWT_SECRET=PASTE_OUTPUT_OF_openssl_rand_-base64_48
JWT_ISSUER=DailyMeals
JWT_AUDIENCE=DailyMeals
# Must match the URL users type in the browser (scheme + host, no trailing slash)
CORS_ALLOWED_ORIGIN=https://meals.example.com
```
Generate JWT secret:
```bash
openssl rand -base64 48
```
Build and start (production overlay hides public API port 5000):
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
Check status:
```bash
docker compose ps
docker compose logs -f api
docker compose logs -f frontend
curl -k https://localhost/health # via nginx proxy: use /api path — health is on API
curl -k https://localhost/api/../health # wrong — health is NOT proxied by default
```
API health is internal. Verify the site loads in a browser: `https://your-server-ip` (self-signed cert until step 4).
---
## 4. DNS and HTTPS (Lets Encrypt)
1. Create an **A record**: `meals.example.com` → your servers public IP.
2. Wait for DNS to propagate.
### Option A — Certificates on the host, mounted into Docker (recommended)
Install Certbot:
```bash
sudo apt install -y certbot
```
Stop the frontend container so port 80 is free:
```bash
cd ~/dailymeals
docker compose -f docker-compose.yml -f docker-compose.prod.yml stop frontend
```
Obtain certificate:
```bash
sudo certbot certonly --standalone -d meals.example.com --agree-tos -m you@example.com
```
Copy and edit the TLS compose file:
```bash
cp docker-compose.tls.example.yml docker-compose.tls.yml
nano docker-compose.tls.yml # set your domain paths under /etc/letsencrypt/live/...
```
Ensure `.env` has:
```env
CORS_ALLOWED_ORIGIN=https://meals.example.com
```
Start again:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
```
Renewal (add to crontab, renew then reload):
```bash
sudo certbot renew --deploy-hook "cd /home/YOUR_USER/dailymeals && docker compose -f docker-compose.yml -f docker-compose.prod.yml restart frontend"
```
### Option B — Keep self-signed (testing only)
Skip `TLS_*` in `.env`. The image generates a self-signed cert. Browsers will show a security warning.
---
## 5. Post-deploy checklist
| Check | Command / action |
|--------|------------------|
| Containers running | `docker compose ps` |
| API connects to DB | `docker compose logs api` — no connection errors on startup |
| Login / register | Open `https://meals.example.com/register` |
| HTTPS valid | Padlock in browser (with Lets Encrypt) |
| Firewall | Only 22, 80, 443 (and 5432 **not** open to the world on app server) |
---
## 6. Updates (new version)
```bash
cd ~/dailymeals
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
---
## 7. Important production notes
### Refresh tokens are in-memory
The API stores refresh tokens **in RAM**. After a container restart, users must log in again. For a **single** API container this is fine. Do **not** scale the API to multiple replicas without replacing `InMemoryRefreshTokenStore` with Redis or similar.
### Logs
API logs: `docker compose logs api` and files under the container at `logs/mealplan-*.log` (ephemeral unless you mount a volume).
### Backups
Back up your **PostgreSQL** database on the DB host. This app does not manage backups.
### Swagger
Swagger is **disabled** in `Production` (`ASPNETCORE_ENVIRONMENT=Production` in compose).
---
## Troubleshooting
| Problem | What to check |
|---------|----------------|
| 502 / empty `/api` | `docker compose logs api`; DB connection string; is `api` healthy? |
| CORS errors in browser | `CORS_ALLOWED_ORIGIN` must exactly match `https://your-domain` |
| DB connection refused | Firewall, `pg_hba.conf`, wrong host/port in `DB_CONNECTION_STRING` |
| Certificate errors | Paths in `docker-compose.tls.yml`; cert files readable inside container |
| Register/login 404 on API | Rebuild images: `docker compose ... up -d --build` |
---
## Minimal command reference
```bash
# Start
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# Stop
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
# Logs
docker compose logs -f api frontend
# Restart one service
docker compose restart api
```

View File

@@ -0,0 +1,10 @@
node_modules
dist
.git
.gitignore
Dockerfile
.dockerignore
*.log
.DS_Store
.vscode
.idea

15
meal-plan-frontend/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/projectSettingsUpdater.xml
/modules.xml
/contentModel.xml
/.idea.meal-plan-frontend.iml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

4
meal-plan-frontend/.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
meal-plan-frontend/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,32 @@
# ---------- Build stage ----------
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies first for better layer caching.
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build
# ---------- Serve stage ----------
FROM nginx:alpine AS final
# OpenSSL is used to generate a self-signed certificate so HTTPS works out of the box.
RUN apk add --no-cache openssl && \
mkdir -p /etc/nginx/certs && \
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout /etc/nginx/certs/server.key \
-out /etc/nginx/certs/server.crt \
-subj "/CN=dailymeals.local" && \
chmod 600 /etc/nginx/certs/server.key
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 443
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --no-check-certificate --tries=1 --spider https://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,20 @@
# meal-plan-frontend
This folder is part of the **DailyMeals** solution. Open `../DailyMeals.sln` in Rider, not this directory alone.
## npm without Homebrew
From the **DailyMeals** root:
```bash
./scripts/install-node.sh
cd meal-plan-frontend
../scripts/npm install
../scripts/npm run dev
```
Or add to your shell for this session:
```bash
export PATH="$(cd .. && pwd)/.toolchain/node-v22.22.0-darwin-arm64/bin:$PATH"
```

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>DailyMeals</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server_tokens off;
# ---- gzip compression ----
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
# Upstream ASP.NET Core API (service name from docker-compose).
upstream api_upstream {
server api:5000;
}
# ---- Redirect all HTTP to HTTPS ----
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# ---- HTTPS server ----
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
root /usr/share/nginx/html;
index index.html;
# ---- Security headers (mirrored at the edge) ----
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
# ---- API reverse proxy ----
location /api/ {
proxy_pass http://api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# ---- Static assets: long cache ----
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# ---- SPA fallback for React Router ----
location / {
try_files $uri $uri/ /index.html;
}
}
}

3296
meal-plan-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "meal-plan-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.59.16",
"axios": "^1.7.7",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lucide-react": "^0.456.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.1",
"react-router-dom": "^6.27.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#16774c" />
<path
d="M11 7v8a3 3 0 0 0 3 3v7a1 1 0 0 0 2 0v-7a3 3 0 0 0 3-3V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0Z"
fill="#fff"
/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { PrivateRoute } from "@/components/layout/PrivateRoute";
import { LoginPage } from "@/pages/LoginPage";
import { RegisterPage } from "@/pages/RegisterPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { BreakfastPage } from "@/pages/BreakfastPage";
import { SecondBreakfastPage } from "@/pages/SecondBreakfastPage";
import { LunchPage } from "@/pages/LunchPage";
import { DinnerPage } from "@/pages/DinnerPage";
import { AllMealsPage } from "@/pages/AllMealsPage";
import { RecipeDetailPage } from "@/pages/RecipeDetailPage";
import { NotFoundPage } from "@/pages/NotFoundPage";
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/breakfast" element={<BreakfastPage />} />
<Route path="/second-breakfast" element={<SecondBreakfastPage />} />
<Route path="/lunch" element={<LunchPage />} />
<Route path="/dinner" element={<DinnerPage />} />
<Route path="/all-meals" element={<AllMealsPage />} />
<Route path="/recipes/:id" element={<RecipeDetailPage />} />
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,21 @@
import { api } from "./axiosInstance";
import type { LoginRequest, RegisterRequest, TokenResponse, UserInfo } from "@/types/auth.types";
export async function register(payload: RegisterRequest): Promise<TokenResponse> {
const { data } = await api.post<TokenResponse>("/auth/register", payload);
return data;
}
export async function login(payload: LoginRequest): Promise<TokenResponse> {
const { data } = await api.post<TokenResponse>("/auth/login", payload);
return data;
}
export async function logout(refreshToken: string): Promise<void> {
await api.post("/auth/logout", { refreshToken });
}
export async function fetchCurrentUser(): Promise<UserInfo> {
const { data } = await api.get<UserInfo>("/auth/me");
return data;
}

View File

@@ -0,0 +1,127 @@
import axios, {
AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import {
clearAuth,
getAccessToken,
getRefreshToken,
setTokens,
} from "@/store/authStore";
import type { TokenResponse } from "@/types/auth.types";
// All calls go through "/api"; nginx (prod) and the Vite dev proxy forward it to the API.
export const api: AxiosInstance = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
// A bare client for the refresh call so it never triggers the response interceptor (no loops).
const refreshClient = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// --- Silent refresh handling -------------------------------------------------
// While a refresh is in flight, queue concurrent 401s and replay them once done.
let isRefreshing = false;
let pendingQueue: Array<(token: string | null) => void> = [];
function flushQueue(token: string | null): void {
pendingQueue.forEach((resolve) => resolve(token));
pendingQueue = [];
}
function redirectToLogin(): void {
clearAuth();
const path = window.location.pathname;
if (path !== "/login" && path !== "/register") {
window.location.assign("/login");
}
}
async function performRefresh(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const { data } = await refreshClient.post<TokenResponse>("/auth/refresh", {
refreshToken,
});
setTokens(data);
return data.accessToken;
} catch {
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
const status = error.response?.status;
const isAuthEndpoint =
original?.url?.includes("/auth/login") ||
original?.url?.includes("/auth/register") ||
original?.url?.includes("/auth/refresh");
if (status !== 401 || !original || original._retry || isAuthEndpoint) {
return Promise.reject(error);
}
original._retry = true;
if (isRefreshing) {
// Wait for the in-flight refresh, then retry (or fail) with its result.
return new Promise((resolve, reject) => {
pendingQueue.push((token) => {
if (!token) {
reject(error);
return;
}
original.headers.Authorization = `Bearer ${token}`;
resolve(api(original));
});
});
}
isRefreshing = true;
const newToken = await performRefresh();
isRefreshing = false;
flushQueue(newToken);
if (!newToken) {
redirectToLogin();
return Promise.reject(error);
}
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
},
);
/** Extracts a user-facing message from an API error response. */
export function extractApiError(error: unknown, fallback = "Something went wrong."): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as { error?: string; title?: string } | undefined;
return data?.error ?? data?.title ?? error.message ?? fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}

View File

@@ -0,0 +1,32 @@
import { api } from "./axiosInstance";
import type {
CategoryCount,
MealCategory,
RecipeDetail,
RecipeListItem,
} from "@/types/recipe.types";
export interface RecipeQuery {
category?: MealCategory;
search?: string;
}
export async function fetchRecipes(query: RecipeQuery = {}): Promise<RecipeListItem[]> {
const { data } = await api.get<RecipeListItem[]>("/recipes", {
params: {
category: query.category,
search: query.search || undefined,
},
});
return data;
}
export async function fetchRecipeById(id: number): Promise<RecipeDetail> {
const { data } = await api.get<RecipeDetail>(`/recipes/${id}`);
return data;
}
export async function fetchCategories(): Promise<CategoryCount[]> {
const { data } = await api.get<CategoryCount[]>("/recipes/categories");
return data;
}

View File

@@ -0,0 +1,20 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Navbar } from "./Navbar";
import { Sidebar } from "./Sidebar";
export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen lg:flex">
<Sidebar open={sidebarOpen} onNavigate={() => setSidebarOpen(false)} />
<div className="flex min-h-screen flex-1 flex-col">
<Navbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Link } from "react-router-dom";
import { LogOut, Moon, Sun, UtensilsCrossed } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { themeStore, toggleTheme } from "@/store/themeStore";
interface NavbarProps {
onToggleSidebar?: () => void;
}
export function Navbar({ onToggleSidebar }: NavbarProps) {
const { user, logout } = useAuth();
const theme = themeStore.useStore((s) => s.theme);
return (
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80">
<div className="flex h-16 items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="btn-secondary !px-2.5 !py-2 lg:hidden"
aria-label="Toggle navigation"
>
<span className="block h-0.5 w-5 bg-current shadow-[0_6px_0_currentColor,0_-6px_0_currentColor]" />
</button>
<Link to="/" className="flex items-center gap-2 font-extrabold tracking-tight">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-brand-600 text-white">
<UtensilsCrossed className="h-5 w-5" />
</span>
<span className="text-lg">DailyMeals</span>
</Link>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<button
type="button"
onClick={toggleTheme}
className="btn-secondary !px-2.5 !py-2"
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
<div className="hidden text-right sm:block">
<p className="text-sm font-semibold leading-tight">{user?.userName ?? "User"}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{user?.email}</p>
</div>
<button type="button" onClick={() => void logout()} className="btn-secondary">
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { authStore } from "@/store/authStore";
interface PrivateRouteProps {
children: ReactNode;
}
/** Guards authenticated routes; redirects unauthenticated users to /login. */
export function PrivateRoute({ children }: PrivateRouteProps) {
const accessToken = authStore.useStore((s) => s.accessToken);
const location = useLocation();
if (!accessToken) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,70 @@
import { NavLink } from "react-router-dom";
import {
Coffee,
Croissant,
LayoutDashboard,
ListChecks,
Moon,
Soup,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
}
const NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/breakfast", label: "Breakfast", icon: Coffee },
{ to: "/second-breakfast", label: "Second Breakfast", icon: Croissant },
{ to: "/lunch", label: "Lunch", icon: Soup },
{ to: "/dinner", label: "Dinner", icon: Moon },
{ to: "/all-meals", label: "All Meals", icon: ListChecks },
];
interface SidebarProps {
open: boolean;
onNavigate?: () => void;
}
export function Sidebar({ open, onNavigate }: SidebarProps) {
return (
<>
{open && (
<div
className="fixed inset-0 z-30 bg-slate-900/40 lg:hidden"
onClick={onNavigate}
aria-hidden="true"
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-40 w-64 transform border-r border-slate-200 bg-white px-3 py-6 transition-transform dark:border-slate-800 dark:bg-slate-900 lg:static lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<nav className="mt-16 flex flex-col gap-1 lg:mt-0">
{NAV_ITEMS.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
onClick={onNavigate}
className={({ isActive }) =>
`flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition ${
isActive
? "bg-brand-600 text-white"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}`
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
</aside>
</>
);
}

View File

@@ -0,0 +1,263 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { FileDown, Loader2 } from "lucide-react";
import { fetchRecipeById } from "@/api/recipes.api";
import { extractApiError } from "@/api/axiosInstance";
import {
buildShoppingList,
formatBreakdown,
formatQuantity,
generatePdf,
} from "@/utils/pdfExport";
import { MEAL_CATEGORY_LABELS, type RecipeDetail } from "@/types/recipe.types";
interface PdfGeneratorProps {
selectedIds: number[];
disabled?: boolean;
}
type Phase = "idle" | "loading" | "rendering";
const PAGE_STYLE: CSSProperties = {
width: "794px",
minHeight: "1123px",
boxSizing: "border-box",
padding: "48px",
fontFamily: "Inter, Arial, sans-serif",
color: "#0f172a",
background: "#ffffff",
};
export function PdfGenerator({ selectedIds, disabled }: PdfGeneratorProps) {
const [phase, setPhase] = useState<Phase>("idle");
const [recipes, setRecipes] = useState<RecipeDetail[]>([]);
const [error, setError] = useState<string | null>(null);
const renderAreaRef = useRef<HTMLDivElement | null>(null);
async function handleGenerate() {
if (selectedIds.length === 0 || phase !== "idle") return;
setError(null);
setPhase("loading");
try {
const details = await Promise.all(selectedIds.map((id) => fetchRecipeById(id)));
setRecipes(details);
setPhase("rendering");
} catch (err) {
setError(extractApiError(err, "Could not load recipes for export."));
setPhase("idle");
}
}
useEffect(() => {
if (phase !== "rendering" || recipes.length === 0) return;
let cancelled = false;
const run = async () => {
// Allow fonts/layout to settle before rasterizing.
await document.fonts?.ready?.catch(() => undefined);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
if (cancelled) return;
try {
await generatePdf("dailymeals-plan.pdf");
} catch (err) {
setError(extractApiError(err, "PDF generation failed."));
} finally {
if (!cancelled) {
setPhase("idle");
setRecipes([]);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [phase, recipes]);
const shoppingList = recipes.length > 0 ? buildShoppingList(recipes) : [];
const generatedOn = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={disabled || selectedIds.length === 0 || phase !== "idle"}
className="btn-primary"
>
{phase !== "idle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileDown className="h-4 w-4" />
)}
{phase === "loading" ? "Preparing..." : phase === "rendering" ? "Generating..." : "Generate PDF"}
</button>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
{phase === "rendering" && (
<div id="pdf-render-area" ref={renderAreaRef}>
{recipes.map((recipe) => (
<div className="pdf-page" style={PAGE_STYLE} key={recipe.id}>
<RecipePdfPage recipe={recipe} />
</div>
))}
<div className="pdf-page" style={PAGE_STYLE}>
<ShoppingListPdfPage
items={shoppingList}
recipes={recipes}
generatedOn={generatedOn}
/>
</div>
</div>
)}
</>
);
}
function RecipePdfPage({ recipe }: { recipe: RecipeDetail }) {
return (
<div>
<div style={{ borderBottom: "3px solid #16774c", paddingBottom: "16px", marginBottom: "24px" }}>
<span
style={{
display: "inline-block",
background: "#d6f1e0",
color: "#125f3f",
borderRadius: "999px",
padding: "4px 12px",
fontSize: "12px",
fontWeight: 700,
}}
>
{MEAL_CATEGORY_LABELS[recipe.mealCategory]}
</span>
<h1 style={{ fontSize: "28px", fontWeight: 800, margin: "12px 0 0" }}>{recipe.name}</h1>
{recipe.prepTimeMinutes != null && (
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>
Prep time: {recipe.prepTimeMinutes} min
</p>
)}
</div>
<div style={{ display: "flex", gap: "12px", marginBottom: "28px" }}>
{[
{ label: "Calories", value: recipe.calories, suffix: "kcal" },
{ label: "Protein", value: recipe.protein, suffix: "g" },
{ label: "Fat", value: recipe.fat, suffix: "g" },
{ label: "Carbs", value: recipe.carbs, suffix: "g" },
].map((m) => (
<div
key={m.label}
style={{
flex: 1,
border: "1px solid #e2e8f0",
borderRadius: "12px",
padding: "12px",
textAlign: "center",
}}
>
<div style={{ fontSize: "20px", fontWeight: 800 }}>
{m.value != null ? `${m.value} ${m.suffix}` : "—"}
</div>
<div style={{ fontSize: "11px", textTransform: "uppercase", color: "#64748b", marginTop: "2px" }}>
{m.label}
</div>
</div>
))}
</div>
<h2 style={{ fontSize: "18px", fontWeight: 700, margin: "0 0 12px" }}>Ingredients</h2>
<ul style={{ margin: "0 0 28px", padding: 0, listStyle: "none" }}>
{recipe.ingredients.map((ing) => (
<li
key={ing.id}
style={{
display: "flex",
justifyContent: "space-between",
padding: "7px 0",
borderBottom: "1px solid #f1f5f9",
fontSize: "14px",
}}
>
<span>{ing.name}</span>
<span style={{ color: "#64748b" }}>
{ing.amountGrams != null
? `${ing.amountGrams} ${ing.unit ?? "g"}`
: ing.unit ?? ""}
</span>
</li>
))}
</ul>
<h2 style={{ fontSize: "18px", fontWeight: 700, margin: "0 0 12px" }}>Preparation</h2>
<ol style={{ margin: 0, paddingLeft: "20px", fontSize: "14px", lineHeight: 1.6 }}>
{recipe.steps.map((step) => (
<li key={step.id} style={{ marginBottom: "8px" }}>
{step.description}
</li>
))}
</ol>
</div>
);
}
function ShoppingListPdfPage({
items,
recipes,
generatedOn,
}: {
items: ReturnType<typeof buildShoppingList>;
recipes: RecipeDetail[];
generatedOn: string;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "1027px" }}>
<div style={{ borderBottom: "3px solid #16774c", paddingBottom: "16px", marginBottom: "24px" }}>
<h1 style={{ fontSize: "28px", fontWeight: 800, margin: 0 }}>Shopping List</h1>
<p style={{ color: "#64748b", margin: "6px 0 0", fontSize: "14px" }}>
Generated {generatedOn} · {recipes.length} recipe{recipes.length === 1 ? "" : "s"}
</p>
</div>
<ul style={{ flex: 1, margin: 0, padding: 0, listStyle: "none" }}>
{items.map((item) => {
const breakdown = formatBreakdown(item);
return (
<li
key={`${item.name}-${item.unit ?? "g"}-${item.quantified}`}
style={{
display: "flex",
alignItems: "baseline",
padding: "8px 0",
borderBottom: "1px solid #f1f5f9",
fontSize: "14px",
}}
>
<span style={{ flex: "0 0 240px", fontWeight: 600 }}>{item.name}</span>
<span style={{ flex: "0 0 110px", color: "#0f172a" }}> {formatQuantity(item)}</span>
{breakdown && <span style={{ color: "#94a3b8", fontSize: "12px" }}>({breakdown})</span>}
</li>
);
})}
</ul>
<div style={{ marginTop: "32px", borderTop: "1px solid #e2e8f0", paddingTop: "16px" }}>
<h3 style={{ fontSize: "13px", fontWeight: 700, textTransform: "uppercase", color: "#64748b", margin: "0 0 10px" }}>
Recipes in this export
</h3>
<ol style={{ margin: 0, paddingLeft: "20px", fontSize: "12px", lineHeight: 1.7, color: "#475569" }}>
{recipes.map((r) => (
<li key={r.id}>
{r.name} {MEAL_CATEGORY_LABELS[r.mealCategory]}
{r.calories != null ? ` · ${r.calories} kcal` : ""}
</li>
))}
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import { Clock, Flame } from "lucide-react";
import type { RecipeListItem } from "@/types/recipe.types";
interface RecipeCardProps {
recipe: RecipeListItem;
selectable?: boolean;
selected?: boolean;
onToggleSelected?: (id: number) => void;
}
export function RecipeCard({ recipe, selectable, selected, onToggleSelected }: RecipeCardProps) {
return (
<div className="card group relative flex flex-col p-5 hover:shadow-md">
{selectable && (
<label className="absolute right-4 top-4 z-10 flex cursor-pointer items-center">
<input
type="checkbox"
checked={Boolean(selected)}
onChange={() => onToggleSelected?.(recipe.id)}
className="h-5 w-5 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
aria-label={`Select ${recipe.name}`}
/>
</label>
)}
<Link to={`/recipes/${recipe.id}`} className="flex flex-1 flex-col">
<h3 className="pr-8 text-base font-semibold leading-snug group-hover:text-brand-600">
{recipe.name}
</h3>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-5 text-sm text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-2.5 py-1 dark:bg-slate-800">
<Flame className="h-4 w-4 text-orange-500" />
{recipe.calories != null ? `${recipe.calories} kcal` : "—"}
</span>
<span className="inline-flex items-center gap-1.5 rounded-lg bg-slate-100 px-2.5 py-1 dark:bg-slate-800">
<Clock className="h-4 w-4 text-sky-500" />
{recipe.prepTimeMinutes != null ? `${recipe.prepTimeMinutes} min` : "—"}
</span>
</div>
</Link>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { Clock } from "lucide-react";
import { CategoryBadge } from "@/components/ui/CategoryBadge";
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
interface MacroProps {
label: string;
value: number | null;
suffix: string;
accent: string;
}
function Macro({ label, value, suffix, accent }: MacroProps) {
return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
<p className={`text-2xl font-extrabold ${accent}`}>
{value != null ? value : "—"}
{value != null && <span className="text-sm font-semibold"> {suffix}</span>}
</p>
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</p>
</div>
);
}
function formatAmount(amount: number | null, unit: string | null): string {
if (amount == null && !unit) return "";
if (amount == null) return unit ?? "";
const display = Number.isInteger(amount) ? amount.toString() : amount.toString();
return unit ? `${display} ${unit}` : `${display} g`;
}
interface RecipeDetailProps {
recipe: RecipeDetailModel;
}
export function RecipeDetail({ recipe }: RecipeDetailProps) {
return (
<article className="space-y-8">
<header className="space-y-3">
<CategoryBadge category={recipe.mealCategory} />
<h1 className="text-3xl font-extrabold tracking-tight">{recipe.name}</h1>
{recipe.prepTimeMinutes != null && (
<p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
<Clock className="h-4 w-4" />
{recipe.prepTimeMinutes} min preparation
</p>
)}
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Macro label="Calories" value={recipe.calories} suffix="kcal" accent="text-orange-500" />
<Macro label="Protein" value={recipe.protein} suffix="g" accent="text-brand-600" />
<Macro label="Fat" value={recipe.fat} suffix="g" accent="text-amber-500" />
<Macro label="Carbs" value={recipe.carbs} suffix="g" accent="text-sky-500" />
</section>
<div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
<section>
<h2 className="text-lg font-bold">Ingredients</h2>
{recipe.ingredients.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">No ingredients listed.</p>
) : (
<ul className="mt-4 divide-y divide-slate-200 rounded-2xl border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
{recipe.ingredients.map((ing) => (
<li key={ing.id} className="flex items-center justify-between px-4 py-3 text-sm">
<span>{ing.name}</span>
<span className="font-medium text-slate-500 dark:text-slate-400">
{formatAmount(ing.amountGrams, ing.unit)}
</span>
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-lg font-bold">Preparation</h2>
{recipe.steps.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">No steps listed.</p>
) : (
<ol className="mt-4 space-y-4">
{recipe.steps.map((step) => (
<li key={step.id} className="flex gap-4">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
{step.stepNumber}
</span>
<p className="pt-1 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{step.description}
</p>
</li>
))}
</ol>
)}
</section>
</div>
</article>
);
}

View File

@@ -0,0 +1,57 @@
import { Search } from "lucide-react";
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
export type CategoryFilter = MealCategory | "all";
interface RecipeFiltersProps {
active: CategoryFilter;
onChange: (value: CategoryFilter) => void;
search: string;
onSearchChange: (value: string) => void;
}
const PILLS: Array<{ value: CategoryFilter; label: string }> = [
{ value: "all", label: "All" },
{ value: MealCategory.Breakfast, label: MEAL_CATEGORY_LABELS[MealCategory.Breakfast] },
{ value: MealCategory.SecondBreakfast, label: MEAL_CATEGORY_LABELS[MealCategory.SecondBreakfast] },
{ value: MealCategory.Lunch, label: MEAL_CATEGORY_LABELS[MealCategory.Lunch] },
{ value: MealCategory.Dinner, label: MEAL_CATEGORY_LABELS[MealCategory.Dinner] },
];
export function RecipeFilters({ active, onChange, search, onSearchChange }: RecipeFiltersProps) {
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
{PILLS.map((pill) => {
const isActive = pill.value === active;
return (
<button
key={String(pill.value)}
type="button"
onClick={() => onChange(pill.value)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
isActive
? "bg-brand-600 text-white"
: "border border-slate-300 bg-white text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
}`}
>
{pill.label}
</button>
);
})}
</div>
<div className="relative md:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search recipes..."
className="input pl-9"
aria-label="Search recipes by name"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
const STYLES: Record<MealCategory, string> = {
[MealCategory.Breakfast]: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
[MealCategory.SecondBreakfast]: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
[MealCategory.Lunch]: "bg-brand-100 text-brand-800 dark:bg-brand-900/40 dark:text-brand-300",
[MealCategory.Dinner]: "bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300",
};
interface CategoryBadgeProps {
category: MealCategory;
}
export function CategoryBadge({ category }: CategoryBadgeProps) {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${STYLES[category] ?? ""}`}
>
{MEAL_CATEGORY_LABELS[category] ?? "Unknown"}
</span>
);
}

View File

@@ -0,0 +1,23 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Inbox } from "lucide-react";
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon: Icon = Inbox, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 px-6 py-16 text-center dark:border-slate-700">
<div className="rounded-full bg-slate-100 p-4 dark:bg-slate-800">
<Icon className="h-8 w-8 text-slate-400" />
</div>
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
{description && <p className="mt-1 max-w-sm text-sm text-slate-500 dark:text-slate-400">{description}</p>}
{action && <div className="mt-6">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { AlertTriangle } from "lucide-react";
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div
role="alert"
className="flex flex-col items-center justify-center rounded-2xl border border-red-200 bg-red-50 px-6 py-12 text-center dark:border-red-900/50 dark:bg-red-950/30"
>
<AlertTriangle className="h-8 w-8 text-red-500" />
<p className="mt-3 text-sm font-medium text-red-700 dark:text-red-300">{message}</p>
{onRetry && (
<button type="button" onClick={onRetry} className="btn-secondary mt-5">
Try again
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-lg bg-slate-200 dark:bg-slate-800 ${className}`}
aria-hidden="true"
/>
);
}
export function RecipeCardSkeleton() {
return (
<div className="card p-5">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="mt-4 h-4 w-1/2" />
<div className="mt-6 flex gap-3">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
);
}
export function GridSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, i) => (
<RecipeCardSkeleton key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as authApi from "@/api/auth.api";
import { extractApiError } from "@/api/axiosInstance";
import {
authStore,
clearAuth,
getRefreshToken,
setTokens,
setUser,
} from "@/store/authStore";
import type { LoginRequest, RegisterRequest } from "@/types/auth.types";
export function useAuth() {
const queryClient = useQueryClient();
const user = authStore.useStore((s) => s.user);
const accessToken = authStore.useStore((s) => s.accessToken);
const isAuthenticated = Boolean(accessToken);
const establishSession = async (tokens: Awaited<ReturnType<typeof authApi.login>>) => {
setTokens(tokens);
const me = await authApi.fetchCurrentUser();
setUser(me);
return me;
};
const loginMutation = useMutation({
mutationFn: async (payload: LoginRequest) => establishSession(await authApi.login(payload)),
});
const registerMutation = useMutation({
mutationFn: async (payload: RegisterRequest) =>
establishSession(await authApi.register(payload)),
});
const logout = useCallback(async () => {
const refreshToken = getRefreshToken();
try {
if (refreshToken) {
await authApi.logout(refreshToken);
}
} finally {
clearAuth();
queryClient.clear();
}
}, [queryClient]);
return {
user,
isAuthenticated,
login: loginMutation.mutateAsync,
loginPending: loginMutation.isPending,
loginError: loginMutation.isError
? extractApiError(loginMutation.error, "Unable to sign in.")
: null,
resetLoginError: loginMutation.reset,
register: registerMutation.mutateAsync,
registerPending: registerMutation.isPending,
registerError: registerMutation.isError
? extractApiError(registerMutation.error, "Unable to create account.")
: null,
resetRegisterError: registerMutation.reset,
logout,
};
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import * as recipesApi from "@/api/recipes.api";
import type { MealCategory } from "@/types/recipe.types";
const keys = {
all: ["recipes"] as const,
list: (category?: MealCategory, search?: string) =>
[...keys.all, "list", category ?? "all", search ?? ""] as const,
detail: (id: number) => [...keys.all, "detail", id] as const,
categories: () => [...keys.all, "categories"] as const,
};
export function useRecipes(category?: MealCategory, search?: string) {
return useQuery({
queryKey: keys.list(category, search),
queryFn: () => recipesApi.fetchRecipes({ category, search }),
});
}
export function useRecipe(id: number) {
return useQuery({
queryKey: keys.detail(id),
queryFn: () => recipesApi.fetchRecipeById(id),
enabled: Number.isFinite(id) && id > 0,
});
}
export function useCategories() {
return useQuery({
queryKey: keys.categories(),
queryFn: recipesApi.fetchCategories,
});
}

View File

@@ -0,0 +1,57 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply antialiased;
}
body {
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100;
}
:focus-visible {
@apply outline-none ring-2 ring-brand-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-950;
}
}
@layer components {
.card {
@apply rounded-2xl border border-slate-200 bg-white shadow-sm transition
dark:border-slate-800 dark:bg-slate-900;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold
transition focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800;
}
.btn-secondary {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100
dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700;
}
.input {
@apply w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900
placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/40
dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500;
}
}
/* Off-screen render surface for PDF generation. Kept renderable (not display:none)
so html2canvas can measure and rasterize it. */
#pdf-render-area {
position: fixed;
left: -10000px;
top: 0;
width: 794px; /* ~A4 width at 96dpi */
background: #ffffff;
color: #0f172a;
z-index: -1;
pointer-events: none;
}

View File

@@ -0,0 +1,29 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import { initTheme } from "@/store/themeStore";
import "./index.css";
initTheme();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from "react";
import { CheckSquare, Square, UtensilsCrossed } from "lucide-react";
import { useRecipes } from "@/hooks/useRecipes";
import { RecipeCard } from "@/components/recipes/RecipeCard";
import { RecipeFilters, type CategoryFilter } from "@/components/recipes/RecipeFilters";
import { PdfGenerator } from "@/components/pdf/PdfGenerator";
import { GridSkeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
export function AllMealsPage() {
const { data: recipes, isLoading, isError, error, refetch } = useRecipes();
const [filter, setFilter] = useState<CategoryFilter>("all");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const visible = useMemo(() => {
if (!recipes) return [];
const term = search.trim().toLowerCase();
return recipes.filter((r) => {
const matchesCategory = filter === "all" || r.mealCategory === filter;
const matchesSearch = term === "" || r.name.toLowerCase().includes(term);
return matchesCategory && matchesSearch;
});
}, [recipes, filter, search]);
const toggleSelected = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const selectAllVisible = () => {
setSelected((prev) => {
const next = new Set(prev);
visible.forEach((r) => next.add(r.id));
return next;
});
};
const deselectAll = () => setSelected(new Set());
const selectedIds = useMemo(() => Array.from(selected), [selected]);
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">All Meals</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Select recipes and export them with a combined shopping list.
</p>
</div>
<span className="inline-flex items-center gap-2 rounded-full bg-brand-100 px-3.5 py-1.5 text-sm font-semibold text-brand-800 dark:bg-brand-900/40 dark:text-brand-300">
{selected.size} selected
</span>
</header>
<RecipeFilters active={filter} onChange={setFilter} search={search} onSearchChange={setSearch} />
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={selectAllVisible}
disabled={visible.length === 0}
className="btn-secondary"
>
<CheckSquare className="h-4 w-4" />
Select all visible
</button>
<button
type="button"
onClick={deselectAll}
disabled={selected.size === 0}
className="btn-secondary"
>
<Square className="h-4 w-4" />
Deselect all
</button>
<div className="ml-auto">
<PdfGenerator selectedIds={selectedIds} />
</div>
</div>
{isLoading && <GridSkeleton count={9} />}
{isError && (
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
)}
{!isLoading && !isError && visible.length === 0 && (
<EmptyState
icon={UtensilsCrossed}
title="No matching recipes"
description="Try adjusting your search or category filter."
/>
)}
{!isLoading && !isError && visible.length > 0 && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
selectable
selected={selected.has(recipe.id)}
onToggleSelected={toggleSelected}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function BreakfastPage() {
return <CategoryPage category={MealCategory.Breakfast} />;
}

View File

@@ -0,0 +1,50 @@
import { UtensilsCrossed } from "lucide-react";
import { useRecipes } from "@/hooks/useRecipes";
import { MEAL_CATEGORY_LABELS, MealCategory } from "@/types/recipe.types";
import { RecipeCard } from "@/components/recipes/RecipeCard";
import { GridSkeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
interface CategoryPageProps {
category: MealCategory;
}
export function CategoryPage({ category }: CategoryPageProps) {
const { data: recipes, isLoading, isError, error, refetch } = useRecipes(category);
const label = MEAL_CATEGORY_LABELS[category];
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{label}</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
{recipes ? `${recipes.length} recipe${recipes.length === 1 ? "" : "s"}` : "Loading..."}
</p>
</header>
{isLoading && <GridSkeleton />}
{isError && (
<ErrorState message={extractApiError(error, "Failed to load recipes.")} onRetry={() => void refetch()} />
)}
{!isLoading && !isError && recipes && recipes.length === 0 && (
<EmptyState
icon={UtensilsCrossed}
title={`No ${label.toLowerCase()} recipes yet`}
description="There are currently no recipes in this category."
/>
)}
{!isLoading && !isError && recipes && recipes.length > 0 && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Link } from "react-router-dom";
import { ArrowRight, Coffee, Croissant, Moon, Soup } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCategories } from "@/hooks/useRecipes";
import { useAuth } from "@/hooks/useAuth";
import {
MEAL_CATEGORY_LABELS,
MEAL_CATEGORY_ROUTES,
MealCategory,
} from "@/types/recipe.types";
import { Skeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
const CARD_META: Record<MealCategory, { icon: LucideIcon; gradient: string }> = {
[MealCategory.Breakfast]: { icon: Coffee, gradient: "from-amber-400 to-orange-500" },
[MealCategory.SecondBreakfast]: { icon: Croissant, gradient: "from-sky-400 to-blue-500" },
[MealCategory.Lunch]: { icon: Soup, gradient: "from-brand-400 to-brand-600" },
[MealCategory.Dinner]: { icon: Moon, gradient: "from-violet-400 to-purple-600" },
};
export function DashboardPage() {
const { user } = useAuth();
const { data: categories, isLoading, isError, error, refetch } = useCategories();
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">
Welcome back{user?.userName ? `, ${user.userName}` : ""}
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Browse recipes by meal category or explore everything at once.
</p>
</header>
{isError ? (
<ErrorState message={extractApiError(error, "Failed to load categories.")} onRetry={() => void refetch()} />
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{isLoading
? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-40" />)
: categories?.map((cat) => {
const meta = CARD_META[cat.category as MealCategory];
const Icon = meta?.icon ?? Coffee;
return (
<Link
key={cat.category}
to={MEAL_CATEGORY_ROUTES[cat.category as MealCategory]}
className="card group relative overflow-hidden p-6 hover:shadow-lg"
>
<div
className={`absolute -right-6 -top-6 grid h-24 w-24 place-items-center rounded-full bg-gradient-to-br ${meta?.gradient} opacity-90`}
>
<Icon className="h-9 w-9 text-white" />
</div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">Category</p>
<h2 className="mt-1 text-xl font-bold">
{MEAL_CATEGORY_LABELS[cat.category as MealCategory]}
</h2>
<p className="mt-6 text-3xl font-extrabold text-brand-600">{cat.count}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
recipe{cat.count === 1 ? "" : "s"}
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-brand-600 group-hover:gap-2">
View recipes <ArrowRight className="h-4 w-4 transition-all" />
</span>
</Link>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function DinnerPage() {
return <CategoryPage category={MealCategory.Dinner} />;
}

View File

@@ -0,0 +1,131 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { AlertCircle, Loader2, Moon, Sun, UtensilsCrossed } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { authStore } from "@/store/authStore";
import { themeStore, toggleTheme } from "@/store/themeStore";
const loginSchema = z.object({
email: z.string().min(1, "Email is required.").email("Enter a valid email address."),
password: z.string().min(1, "Password is required."),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login, loginPending, loginError, resetLoginError } = useAuth();
const accessToken = authStore.useStore((s) => s.accessToken);
const theme = themeStore.useStore((s) => s.theme);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
useEffect(() => () => resetLoginError(), [resetLoginError]);
const redirectTo = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? "/";
if (accessToken) {
return <Navigate to={redirectTo} replace />;
}
const onSubmit = async (data: LoginForm) => {
try {
await login(data);
navigate(redirectTo, { replace: true });
} catch {
// Error surfaced via loginError below.
}
};
return (
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4 dark:from-slate-950 dark:to-slate-900">
<button
type="button"
onClick={toggleTheme}
className="btn-secondary absolute right-4 top-4 !px-2.5 !py-2"
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
<div className="w-full max-w-md">
<div className="mb-8 flex flex-col items-center text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-brand-600 text-white shadow-lg">
<UtensilsCrossed className="h-7 w-7" />
</span>
<h1 className="mt-4 text-2xl font-extrabold tracking-tight">DailyMeals</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Sign in to plan your meals
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="card space-y-5 p-6 sm:p-8" noValidate>
{loginError && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{loginError}</span>
</div>
)}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
{...register("email")}
/>
{errors.email && <p className="mt-1.5 text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
className="input"
placeholder="••••••••"
{...register("password")}
/>
{errors.password && (
<p className="mt-1.5 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<button type="submit" disabled={loginPending} className="btn-primary w-full">
{loginPending && <Loader2 className="h-4 w-4 animate-spin" />}
{loginPending ? "Signing in..." : "Sign in"}
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Don&apos;t have an account?{" "}
<Link to="/register" className="font-semibold text-brand-600 hover:text-brand-700">
Create one
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function LunchPage() {
return <CategoryPage category={MealCategory.Lunch} />;
}

View File

@@ -0,0 +1,16 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<p className="text-6xl font-black text-brand-600">404</p>
<h1 className="mt-4 text-xl font-bold">Page not found</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
The page youre looking for doesnt exist.
</p>
<Link to="/" className="btn-primary mt-6">
Back to dashboard
</Link>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { ArrowLeft } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useRecipe } from "@/hooks/useRecipes";
import { RecipeDetail } from "@/components/recipes/RecipeDetail";
import { Skeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance";
export function RecipeDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const recipeId = Number(id);
const { data: recipe, isLoading, isError, error, refetch } = useRecipe(recipeId);
return (
<div className="space-y-6">
<button type="button" onClick={() => navigate(-1)} className="btn-secondary">
<ArrowLeft className="h-4 w-4" />
Back
</button>
{isLoading && (
<div className="space-y-6">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-9 w-2/3" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
<Skeleton className="h-64" />
</div>
)}
{isError && (
<ErrorState
message={extractApiError(error, "Failed to load recipe.")}
onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && recipe && <RecipeDetail recipe={recipe} />}
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { AlertCircle, Loader2, Moon, Sun, UtensilsCrossed } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { authStore } from "@/store/authStore";
import { themeStore, toggleTheme } from "@/store/themeStore";
const registerSchema = z
.object({
email: z.string().min(1, "Email is required.").email("Enter a valid email address."),
userName: z.string().max(256, "Display name is too long.").optional(),
password: z
.string()
.min(8, "Password must be at least 8 characters.")
.regex(/[A-Z]/, "Include at least one uppercase letter.")
.regex(/[a-z]/, "Include at least one lowercase letter.")
.regex(/[0-9]/, "Include at least one digit."),
confirmPassword: z.string().min(1, "Please confirm your password."),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
type RegisterForm = z.infer<typeof registerSchema>;
export function RegisterPage() {
const navigate = useNavigate();
const { register: registerUser, registerPending, registerError, resetRegisterError } = useAuth();
const accessToken = authStore.useStore((s) => s.accessToken);
const theme = themeStore.useStore((s) => s.theme);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
defaultValues: { email: "", userName: "", password: "", confirmPassword: "" },
});
useEffect(() => () => resetRegisterError(), [resetRegisterError]);
if (accessToken) {
return <Navigate to="/" replace />;
}
const onSubmit = async (data: RegisterForm) => {
try {
await registerUser({
email: data.email,
password: data.password,
userName: data.userName?.trim() || undefined,
});
navigate("/", { replace: true });
} catch {
// Error surfaced via registerError below.
}
};
return (
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4 dark:from-slate-950 dark:to-slate-900">
<button
type="button"
onClick={toggleTheme}
className="btn-secondary absolute right-4 top-4 !px-2.5 !py-2"
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
<div className="w-full max-w-md">
<div className="mb-8 flex flex-col items-center text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-brand-600 text-white shadow-lg">
<UtensilsCrossed className="h-7 w-7" />
</span>
<h1 className="mt-4 text-2xl font-extrabold tracking-tight">Create account</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Join DailyMeals to browse and plan meals
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="card space-y-5 p-6 sm:p-8" noValidate>
{registerError && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{registerError}</span>
</div>
)}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
{...register("email")}
/>
{errors.email && <p className="mt-1.5 text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="userName" className="mb-1.5 block text-sm font-medium">
Display name <span className="text-slate-400">(optional)</span>
</label>
<input
id="userName"
type="text"
autoComplete="name"
className="input"
placeholder="Piotr"
{...register("userName")}
/>
{errors.userName && (
<p className="mt-1.5 text-sm text-red-600">{errors.userName.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
className="input"
placeholder="••••••••"
{...register("password")}
/>
{errors.password && (
<p className="mt-1.5 text-sm text-red-600">{errors.password.message}</p>
)}
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
At least 8 characters with uppercase, lowercase, and a number.
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="mb-1.5 block text-sm font-medium">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
className="input"
placeholder="••••••••"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="mt-1.5 text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" disabled={registerPending} className="btn-primary w-full">
{registerPending && <Loader2 className="h-4 w-4 animate-spin" />}
{registerPending ? "Creating account..." : "Create account"}
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Already have an account?{" "}
<Link to="/login" className="font-semibold text-brand-600 hover:text-brand-700">
Sign in
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { CategoryPage } from "./CategoryPage";
import { MealCategory } from "@/types/recipe.types";
export function SecondBreakfastPage() {
return <CategoryPage category={MealCategory.SecondBreakfast} />;
}

View File

@@ -0,0 +1,35 @@
import { createStore } from "./createStore";
import type { TokenResponse, UserInfo } from "@/types/auth.types";
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: UserInfo | null;
}
const initialState: AuthState = {
accessToken: null,
refreshToken: null,
user: null,
};
export const authStore = createStore<AuthState>(initialState);
export function setTokens(tokens: TokenResponse): void {
authStore.setState({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
}
export function setUser(user: UserInfo | null): void {
authStore.setState({ user });
}
export function clearAuth(): void {
authStore.setState({ accessToken: null, refreshToken: null, user: null });
}
export const getAccessToken = (): string | null => authStore.getState().accessToken;
export const getRefreshToken = (): string | null => authStore.getState().refreshToken;
export const isAuthenticated = (): boolean => Boolean(authStore.getState().accessToken);

View File

@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
/**
* Minimal dependency-free observable store built on useSyncExternalStore.
* Keeps app state in memory only (no localStorage / sessionStorage), as required.
*/
export interface Store<T> {
getState: () => T;
setState: (partial: Partial<T> | ((prev: T) => Partial<T>)) => void;
subscribe: (listener: () => void) => () => void;
useStore: <S>(selector: (state: T) => S) => S;
}
export function createStore<T>(initial: T): Store<T> {
let state = initial;
const listeners = new Set<() => void>();
const getState = () => state;
const setState: Store<T>["setState"] = (partial) => {
const next = typeof partial === "function" ? partial(state) : partial;
state = { ...state, ...next };
listeners.forEach((l) => l());
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const useStore = <S,>(selector: (s: T) => S): S =>
useSyncExternalStore(
subscribe,
() => selector(state),
() => selector(state),
);
return { getState, setState, subscribe, useStore };
}

View File

@@ -0,0 +1,32 @@
import { createStore } from "./createStore";
export type Theme = "light" | "dark";
function detectInitialTheme(): Theme {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
}
interface ThemeState {
theme: Theme;
}
// Preference is held in memory only (no localStorage), defaulting to the OS setting.
export const themeStore = createStore<ThemeState>({ theme: detectInitialTheme() });
function applyTheme(theme: Theme): void {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
}
export function initTheme(): void {
applyTheme(themeStore.getState().theme);
}
export function toggleTheme(): void {
const next: Theme = themeStore.getState().theme === "dark" ? "light" : "dark";
themeStore.setState({ theme: next });
applyTheme(next);
}

Some files were not shown because too many files have changed in this diff Show More