Initial commit
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal 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
26
.gitignore
vendored
Normal 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
|
||||||
14
.idea/.idea.DailyMeals/.idea/runConfigurations/MealPlan_Api__http_.xml
generated
Normal file
14
.idea/.idea.DailyMeals/.idea/runConfigurations/MealPlan_Api__http_.xml
generated
Normal 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>
|
||||||
7
.idea/.idea.DailyMeals/.idea/runConfigurations/_template__of_Compound.xml
generated
Normal file
7
.idea/.idea.DailyMeals/.idea/runConfigurations/_template__of_Compound.xml
generated
Normal 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>
|
||||||
13
.idea/.idea.DailyMeals/.idea/runConfigurations/meal_plan_frontend__vite_dev_.xml
generated
Normal file
13
.idea/.idea.DailyMeals/.idea/runConfigurations/meal_plan_frontend__vite_dev_.xml
generated
Normal 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>
|
||||||
14
.run/MealPlan.Api (http).run.xml
Normal file
14
.run/MealPlan.Api (http).run.xml
Normal 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>
|
||||||
13
.run/meal-plan-frontend (vite dev).run.xml
Normal file
13
.run/meal-plan-frontend (vite dev).run.xml
Normal 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
26
DailyMeals.sln
Normal 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
|
||||||
9
MealPlan.Api/.dockerignore
Normal file
9
MealPlan.Api/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
logs/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
appsettings.Development.json
|
||||||
100
MealPlan.Api/Controllers/AuthController.cs
Normal file
100
MealPlan.Api/Controllers/AuthController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
MealPlan.Api/Controllers/RecipesController.cs
Normal file
54
MealPlan.Api/Controllers/RecipesController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
MealPlan.Api/DTOs/Auth/LoginRequestDto.cs
Normal file
16
MealPlan.Api/DTOs/Auth/LoginRequestDto.cs
Normal 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;
|
||||||
|
}
|
||||||
10
MealPlan.Api/DTOs/Auth/RefreshRequestDto.cs
Normal file
10
MealPlan.Api/DTOs/Auth/RefreshRequestDto.cs
Normal 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;
|
||||||
|
}
|
||||||
20
MealPlan.Api/DTOs/Auth/RegisterRequestDto.cs
Normal file
20
MealPlan.Api/DTOs/Auth/RegisterRequestDto.cs
Normal 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;
|
||||||
|
}
|
||||||
13
MealPlan.Api/DTOs/Auth/TokenResponseDto.cs
Normal file
13
MealPlan.Api/DTOs/Auth/TokenResponseDto.cs
Normal 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";
|
||||||
|
}
|
||||||
9
MealPlan.Api/DTOs/Auth/UserInfoDto.cs
Normal file
9
MealPlan.Api/DTOs/Auth/UserInfoDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
9
MealPlan.Api/DTOs/Recipe/CategoryCountDto.cs
Normal file
9
MealPlan.Api/DTOs/Recipe/CategoryCountDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
33
MealPlan.Api/DTOs/Recipe/RecipeDetailDto.cs
Normal file
33
MealPlan.Api/DTOs/Recipe/RecipeDetailDto.cs
Normal 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;
|
||||||
|
}
|
||||||
11
MealPlan.Api/DTOs/Recipe/RecipeListItemDto.cs
Normal file
11
MealPlan.Api/DTOs/Recipe/RecipeListItemDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
65
MealPlan.Api/Data/AppDbContext.cs
Normal file
65
MealPlan.Api/Data/AppDbContext.cs
Normal 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
27
MealPlan.Api/Dockerfile
Normal 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"]
|
||||||
28
MealPlan.Api/MealPlan.Api.csproj
Normal file
28
MealPlan.Api/MealPlan.Api.csproj
Normal 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>
|
||||||
62
MealPlan.Api/Middleware/ExceptionMiddleware.cs
Normal file
62
MealPlan.Api/Middleware/ExceptionMiddleware.cs
Normal 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>();
|
||||||
|
}
|
||||||
32
MealPlan.Api/Models/ApplicationUser.cs
Normal file
32
MealPlan.Api/Models/ApplicationUser.cs
Normal 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; }
|
||||||
|
}
|
||||||
33
MealPlan.Api/Models/Ingredient.cs
Normal file
33
MealPlan.Api/Models/Ingredient.cs
Normal 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; }
|
||||||
|
}
|
||||||
12
MealPlan.Api/Models/MealCategory.cs
Normal file
12
MealPlan.Api/Models/MealCategory.cs
Normal 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
|
||||||
|
}
|
||||||
49
MealPlan.Api/Models/Recipe.cs
Normal file
49
MealPlan.Api/Models/Recipe.cs
Normal 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>();
|
||||||
|
}
|
||||||
24
MealPlan.Api/Models/RecipeStep.cs
Normal file
24
MealPlan.Api/Models/RecipeStep.cs
Normal 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; }
|
||||||
|
}
|
||||||
17
MealPlan.Api/Models/RefreshToken.cs
Normal file
17
MealPlan.Api/Models/RefreshToken.cs
Normal 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
231
MealPlan.Api/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
14
MealPlan.Api/Properties/launchSettings.json
Normal file
14
MealPlan.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
MealPlan.Api/Services/AuthResult.cs
Normal file
17
MealPlan.Api/Services/AuthResult.cs
Normal 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 };
|
||||||
|
}
|
||||||
172
MealPlan.Api/Services/AuthService.cs
Normal file
172
MealPlan.Api/Services/AuthService.cs
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
MealPlan.Api/Services/IAuthService.cs
Normal file
18
MealPlan.Api/Services/IAuthService.cs
Normal 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);
|
||||||
|
}
|
||||||
12
MealPlan.Api/Services/IRecipeService.cs
Normal file
12
MealPlan.Api/Services/IRecipeService.cs
Normal 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);
|
||||||
|
}
|
||||||
20
MealPlan.Api/Services/IRefreshTokenStore.cs
Normal file
20
MealPlan.Api/Services/IRefreshTokenStore.cs
Normal 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();
|
||||||
|
}
|
||||||
16
MealPlan.Api/Services/ITokenService.cs
Normal file
16
MealPlan.Api/Services/ITokenService.cs
Normal 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; }
|
||||||
|
}
|
||||||
43
MealPlan.Api/Services/InMemoryRefreshTokenStore.cs
Normal file
43
MealPlan.Api/Services/InMemoryRefreshTokenStore.cs
Normal 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 _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
MealPlan.Api/Services/JwtOptions.cs
Normal file
17
MealPlan.Api/Services/JwtOptions.cs
Normal 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;
|
||||||
|
}
|
||||||
107
MealPlan.Api/Services/RecipeService.cs
Normal file
107
MealPlan.Api/Services/RecipeService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
MealPlan.Api/Services/RefreshTokenCleanupService.cs
Normal file
35
MealPlan.Api/Services/RefreshTokenCleanupService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
MealPlan.Api/Services/TokenService.cs
Normal file
82
MealPlan.Api/Services/TokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
MealPlan.Api/Validators/LoginRequestValidator.cs
Normal file
19
MealPlan.Api/Validators/LoginRequestValidator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
MealPlan.Api/Validators/RefreshRequestValidator.cs
Normal file
14
MealPlan.Api/Validators/RefreshRequestValidator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
MealPlan.Api/Validators/RegisterRequestValidator.cs
Normal file
27
MealPlan.Api/Validators/RegisterRequestValidator.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
19
MealPlan.Api/appsettings.Development.json
Normal file
19
MealPlan.Api/appsettings.Development.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
MealPlan.Api/appsettings.Local.json.example
Normal file
5
MealPlan.Api/appsettings.Local.json.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=your-db-host;Port=5432;Database=mealplan;Username=user;Password=secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
MealPlan.Api/appsettings.json
Normal file
25
MealPlan.Api/appsettings.json
Normal 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
248
README.md
Normal 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, Let’s 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
12
docker-compose.prod.yml
Normal 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 []
|
||||||
9
docker-compose.tls.example.yml
Normal file
9
docker-compose.tls.example.yml
Normal 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
35
docker-compose.yml
Normal 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
245
docs/DEPLOYMENT.md
Normal 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 server’s public IP** to `pg_hba.conf` (or the provider’s “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 (Let’s Encrypt)
|
||||||
|
|
||||||
|
1. Create an **A record**: `meals.example.com` → your server’s 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 Let’s 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
|
||||||
|
```
|
||||||
10
meal-plan-frontend/.dockerignore
Normal file
10
meal-plan-frontend/.dockerignore
Normal 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
15
meal-plan-frontend/.idea/.gitignore
generated
vendored
Normal 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
4
meal-plan-frontend/.idea/encodings.xml
generated
Normal 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>
|
||||||
8
meal-plan-frontend/.idea/indexLayout.xml
generated
Normal file
8
meal-plan-frontend/.idea/indexLayout.xml
generated
Normal 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
6
meal-plan-frontend/.idea/vcs.xml
generated
Normal 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>
|
||||||
32
meal-plan-frontend/Dockerfile
Normal file
32
meal-plan-frontend/Dockerfile
Normal 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;"]
|
||||||
20
meal-plan-frontend/README.md
Normal file
20
meal-plan-frontend/README.md
Normal 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"
|
||||||
|
```
|
||||||
20
meal-plan-frontend/index.html
Normal file
20
meal-plan-frontend/index.html
Normal 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>
|
||||||
87
meal-plan-frontend/nginx.conf
Normal file
87
meal-plan-frontend/nginx.conf
Normal 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
3296
meal-plan-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
meal-plan-frontend/package.json
Normal file
36
meal-plan-frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
meal-plan-frontend/postcss.config.js
Normal file
6
meal-plan-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
7
meal-plan-frontend/public/favicon.svg
Normal file
7
meal-plan-frontend/public/favicon.svg
Normal 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 |
40
meal-plan-frontend/src/App.tsx
Normal file
40
meal-plan-frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
meal-plan-frontend/src/api/auth.api.ts
Normal file
21
meal-plan-frontend/src/api/auth.api.ts
Normal 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;
|
||||||
|
}
|
||||||
127
meal-plan-frontend/src/api/axiosInstance.ts
Normal file
127
meal-plan-frontend/src/api/axiosInstance.ts
Normal 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;
|
||||||
|
}
|
||||||
32
meal-plan-frontend/src/api/recipes.api.ts
Normal file
32
meal-plan-frontend/src/api/recipes.api.ts
Normal 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;
|
||||||
|
}
|
||||||
20
meal-plan-frontend/src/components/layout/AppLayout.tsx
Normal file
20
meal-plan-frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
meal-plan-frontend/src/components/layout/Navbar.tsx
Normal file
57
meal-plan-frontend/src/components/layout/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
meal-plan-frontend/src/components/layout/PrivateRoute.tsx
Normal file
19
meal-plan-frontend/src/components/layout/PrivateRoute.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
70
meal-plan-frontend/src/components/layout/Sidebar.tsx
Normal file
70
meal-plan-frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
meal-plan-frontend/src/components/pdf/PdfGenerator.tsx
Normal file
263
meal-plan-frontend/src/components/pdf/PdfGenerator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
meal-plan-frontend/src/components/recipes/RecipeCard.tsx
Normal file
45
meal-plan-frontend/src/components/recipes/RecipeCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
meal-plan-frontend/src/components/recipes/RecipeDetail.tsx
Normal file
99
meal-plan-frontend/src/components/recipes/RecipeDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
meal-plan-frontend/src/components/recipes/RecipeFilters.tsx
Normal file
57
meal-plan-frontend/src/components/recipes/RecipeFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
meal-plan-frontend/src/components/ui/CategoryBadge.tsx
Normal file
22
meal-plan-frontend/src/components/ui/CategoryBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
meal-plan-frontend/src/components/ui/EmptyState.tsx
Normal file
23
meal-plan-frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
meal-plan-frontend/src/components/ui/ErrorState.tsx
Normal file
23
meal-plan-frontend/src/components/ui/ErrorState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
meal-plan-frontend/src/components/ui/Skeleton.tsx
Normal file
35
meal-plan-frontend/src/components/ui/Skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
meal-plan-frontend/src/hooks/useAuth.ts
Normal file
66
meal-plan-frontend/src/hooks/useAuth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
33
meal-plan-frontend/src/hooks/useRecipes.ts
Normal file
33
meal-plan-frontend/src/hooks/useRecipes.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
57
meal-plan-frontend/src/index.css
Normal file
57
meal-plan-frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
29
meal-plan-frontend/src/main.tsx
Normal file
29
meal-plan-frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
121
meal-plan-frontend/src/pages/AllMealsPage.tsx
Normal file
121
meal-plan-frontend/src/pages/AllMealsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
meal-plan-frontend/src/pages/BreakfastPage.tsx
Normal file
6
meal-plan-frontend/src/pages/BreakfastPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { CategoryPage } from "./CategoryPage";
|
||||||
|
import { MealCategory } from "@/types/recipe.types";
|
||||||
|
|
||||||
|
export function BreakfastPage() {
|
||||||
|
return <CategoryPage category={MealCategory.Breakfast} />;
|
||||||
|
}
|
||||||
50
meal-plan-frontend/src/pages/CategoryPage.tsx
Normal file
50
meal-plan-frontend/src/pages/CategoryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
meal-plan-frontend/src/pages/DashboardPage.tsx
Normal file
75
meal-plan-frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
meal-plan-frontend/src/pages/DinnerPage.tsx
Normal file
6
meal-plan-frontend/src/pages/DinnerPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { CategoryPage } from "./CategoryPage";
|
||||||
|
import { MealCategory } from "@/types/recipe.types";
|
||||||
|
|
||||||
|
export function DinnerPage() {
|
||||||
|
return <CategoryPage category={MealCategory.Dinner} />;
|
||||||
|
}
|
||||||
131
meal-plan-frontend/src/pages/LoginPage.tsx
Normal file
131
meal-plan-frontend/src/pages/LoginPage.tsx
Normal 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't have an account?{" "}
|
||||||
|
<Link to="/register" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
meal-plan-frontend/src/pages/LunchPage.tsx
Normal file
6
meal-plan-frontend/src/pages/LunchPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { CategoryPage } from "./CategoryPage";
|
||||||
|
import { MealCategory } from "@/types/recipe.types";
|
||||||
|
|
||||||
|
export function LunchPage() {
|
||||||
|
return <CategoryPage category={MealCategory.Lunch} />;
|
||||||
|
}
|
||||||
16
meal-plan-frontend/src/pages/NotFoundPage.tsx
Normal file
16
meal-plan-frontend/src/pages/NotFoundPage.tsx
Normal 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 you’re looking for doesn’t exist.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn-primary mt-6">
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
meal-plan-frontend/src/pages/RecipeDetailPage.tsx
Normal file
45
meal-plan-frontend/src/pages/RecipeDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
meal-plan-frontend/src/pages/RegisterPage.tsx
Normal file
181
meal-plan-frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
meal-plan-frontend/src/pages/SecondBreakfastPage.tsx
Normal file
6
meal-plan-frontend/src/pages/SecondBreakfastPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { CategoryPage } from "./CategoryPage";
|
||||||
|
import { MealCategory } from "@/types/recipe.types";
|
||||||
|
|
||||||
|
export function SecondBreakfastPage() {
|
||||||
|
return <CategoryPage category={MealCategory.SecondBreakfast} />;
|
||||||
|
}
|
||||||
35
meal-plan-frontend/src/store/authStore.ts
Normal file
35
meal-plan-frontend/src/store/authStore.ts
Normal 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);
|
||||||
39
meal-plan-frontend/src/store/createStore.ts
Normal file
39
meal-plan-frontend/src/store/createStore.ts
Normal 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 };
|
||||||
|
}
|
||||||
32
meal-plan-frontend/src/store/themeStore.ts
Normal file
32
meal-plan-frontend/src/store/themeStore.ts
Normal 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
Reference in New Issue
Block a user