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