From 7d95907e93e438a28e1499cc34254cc6d094badf Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Sun, 24 Dec 2023 22:51:24 -0600 Subject: [PATCH 01/13] Add login flow --- .../IAuthenticationService.cs | 7 ++++ WorkoutBuilder.Services/IUserContext.cs | 8 ++++ .../Impl/AuthenticationService.cs | 40 +++++++++++++++++++ .../Impl/ClaimsBasedUserContext.cs | 25 ++++++++++++ .../Models/WorkoutBuilderClaimTypes.cs | 11 +++++ .../WorkoutBuilder.Services.csproj | 1 + WorkoutBuilder/Controllers/HomeController.cs | 1 + WorkoutBuilder/Controllers/UsersController.cs | 25 ++++++++++++ .../IOC/AutofacRegistrationModule.cs | 2 + WorkoutBuilder/Program.cs | 9 +++++ WorkoutBuilder/Views/Shared/_Layout.cshtml | 2 +- WorkoutBuilder/Views/Users/Login.cshtml | 36 +++++++++++++++++ 12 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 WorkoutBuilder.Services/IAuthenticationService.cs create mode 100644 WorkoutBuilder.Services/IUserContext.cs create mode 100644 WorkoutBuilder.Services/Impl/AuthenticationService.cs create mode 100644 WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs create mode 100644 WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs create mode 100644 WorkoutBuilder/Views/Users/Login.cshtml diff --git a/WorkoutBuilder.Services/IAuthenticationService.cs b/WorkoutBuilder.Services/IAuthenticationService.cs new file mode 100644 index 0000000..66d95eb --- /dev/null +++ b/WorkoutBuilder.Services/IAuthenticationService.cs @@ -0,0 +1,7 @@ +namespace WorkoutBuilder.Services +{ + public interface IAuthenticationService + { + Task Login(string username, string password); + } +} diff --git a/WorkoutBuilder.Services/IUserContext.cs b/WorkoutBuilder.Services/IUserContext.cs new file mode 100644 index 0000000..a4e7512 --- /dev/null +++ b/WorkoutBuilder.Services/IUserContext.cs @@ -0,0 +1,8 @@ +namespace WorkoutBuilder.Services +{ + public interface IUserContext + { + long? GetUserId(); + string? GetEmailAddress(); + } +} diff --git a/WorkoutBuilder.Services/Impl/AuthenticationService.cs b/WorkoutBuilder.Services/Impl/AuthenticationService.cs new file mode 100644 index 0000000..8319847 --- /dev/null +++ b/WorkoutBuilder.Services/Impl/AuthenticationService.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using WorkoutBuilder.Data; +using WorkoutBuilder.Services.Models; + +namespace WorkoutBuilder.Services.Impl +{ + public class AuthenticationService : IAuthenticationService + { + public IPasswordHashingService PasswordHashingService { init; protected get; } = null!; + public IRepository UserRepository { init; protected get; } = null!; + public IHttpContextAccessor HttpContextAccessor { init; protected get; } = null!; + + public async Task Login(string username, string password) + { + var user = UserRepository.GetAll().Where(x => x.EmailAddress == username).SingleOrDefault(); + if (user != null && PasswordHashingService.Verify(password, user.Password)) + { + var claims = GetClaims(user); + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + await HttpContextAccessor.HttpContext.SignInAsync(principal); + return true; + } + + return false; + } + + private List GetClaims(User user) + { + return new List + { + new Claim(ClaimTypes.Email, user.EmailAddress), + new Claim(WorkoutBuilderClaimTypes.Id, user.Id.ToString()) + }; + } + } +} diff --git a/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs b/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs new file mode 100644 index 0000000..abc2cb7 --- /dev/null +++ b/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using WorkoutBuilder.Services.Models; + +namespace WorkoutBuilder.Services.Impl +{ + public class ClaimsBasedUserContext : IUserContext + { + public IHttpContextAccessor HttpContextAccessor { init; protected get; } = null!; + + public string? GetEmailAddress() => User?.FindFirst(ClaimTypes.Email)?.Value; + public long? GetUserId() + { + if (User != null) + { + if (long.TryParse(User.FindFirst(WorkoutBuilderClaimTypes.Id)?.Value, out var id)) + return id; + } + + return null; + } + + protected ClaimsPrincipal? User => HttpContextAccessor.HttpContext.User; + } +} diff --git a/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs b/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs new file mode 100644 index 0000000..08f096b --- /dev/null +++ b/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs @@ -0,0 +1,11 @@ +namespace WorkoutBuilder.Services.Models +{ + public class WorkoutBuilderClaimTypes + { + public const string Id = "http://workoutbuild.com/2023/12/identity/claims/Id"; + + public const string Manage = "/Manage"; + public const string Read = "/Read"; + public const string All = "/All"; + } +} diff --git a/WorkoutBuilder.Services/WorkoutBuilder.Services.csproj b/WorkoutBuilder.Services/WorkoutBuilder.Services.csproj index 229fb73..fca1882 100644 --- a/WorkoutBuilder.Services/WorkoutBuilder.Services.csproj +++ b/WorkoutBuilder.Services/WorkoutBuilder.Services.csproj @@ -11,6 +11,7 @@ + diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index b54fde9..ba56ed9 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -16,6 +16,7 @@ public class HomeController : Controller public IWorkoutGeneratorFactory WorkoutGeneratorFactory { protected get; init; } = null!; public IEmailService EmailService { protected get; init; } = null!; public IConfiguration Configuration { protected get; init; } = null!; + public IUserContext UserContext { protected get; init; } = null!; public IActionResult Index() { diff --git a/WorkoutBuilder/Controllers/UsersController.cs b/WorkoutBuilder/Controllers/UsersController.cs index ee08725..4f5d2bf 100644 --- a/WorkoutBuilder/Controllers/UsersController.cs +++ b/WorkoutBuilder/Controllers/UsersController.cs @@ -1,9 +1,11 @@ using BotDetect.Web.Mvc; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using WorkoutBuilder.Data; using WorkoutBuilder.Models; using WorkoutBuilder.Services; using WorkoutBuilder.Services.Impl.Helpers; +using IAuthenticationService = WorkoutBuilder.Services.IAuthenticationService; namespace WorkoutBuilder.Controllers { @@ -13,6 +15,7 @@ public class UsersController : Controller public IResetPasswordHelper ResetPasswordHelper { protected get; init; } = null!; public IUserResetPasswordService ResetPasswordService { protected get; init; } = null!; public IRepository PasswordResetRepository { protected get; init; } = null!; + public IAuthenticationService AuthenticationService { protected get; init; } = null!; [HttpGet] public IActionResult ForgotPassword() @@ -37,6 +40,28 @@ public async Task ForgotPassword(UserForgotPasswordModel data) } + [HttpGet] + public IActionResult Login() + { + return View(); + } + + [HttpPost] + public async Task Login(string Username, string Password) + { + if (await AuthenticationService.Login(Username, Password)) + return RedirectToAction("Index", "Home"); + + ViewBag.Error = "Incorrect Username/Password."; + return View(); + } + + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return RedirectToAction("Index", "Home"); + } + [HttpGet] public IActionResult ResetPassword(string id) { diff --git a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs index 9084be8..70f2c6e 100644 --- a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs +++ b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs @@ -29,6 +29,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); + builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); if (Configuration["InjectionMode"] == "development") { diff --git a/WorkoutBuilder/Program.cs b/WorkoutBuilder/Program.cs index 224740f..9cdad31 100644 --- a/WorkoutBuilder/Program.cs +++ b/WorkoutBuilder/Program.cs @@ -32,6 +32,14 @@ public static void Main(string[] args) options.Cookie.IsEssential = true; }); + + builder.Services.AddAuthentication("CookieAuth") + .AddCookie("CookieAuth", config => + { + config.Cookie.Name = "WorkoutBuild"; + config.LoginPath = "/Users/Login"; + }); + // This setting allows the CAPTCHA to generate images builder.Services.Configure(options => options.AllowSynchronousIO = true); builder.Services.Configure(options => options.AllowSynchronousIO = true); @@ -60,6 +68,7 @@ public static void Main(string[] args) app.UseRouting(); + app.UseAuthentication(); app.UseAuthorization(); app.UseSession(); diff --git a/WorkoutBuilder/Views/Shared/_Layout.cshtml b/WorkoutBuilder/Views/Shared/_Layout.cshtml index 53da098..5da9c02 100644 --- a/WorkoutBuilder/Views/Shared/_Layout.cshtml +++ b/WorkoutBuilder/Views/Shared/_Layout.cshtml @@ -59,7 +59,7 @@ Sign up - + Log in diff --git a/WorkoutBuilder/Views/Users/Login.cshtml b/WorkoutBuilder/Views/Users/Login.cshtml new file mode 100644 index 0000000..7b15bf4 --- /dev/null +++ b/WorkoutBuilder/Views/Users/Login.cshtml @@ -0,0 +1,36 @@ +@using WorkoutBuilder.Models; + +@{ + Layout = "_LoginLayout"; +} + +@using (Html.BeginForm(FormMethod.Post, new { @class = "box" })) +{ +

Login

+ @if (ViewBag.Success != null) + { +

+ @ViewBag.Success +

+ } +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ Cancel +
+
+} \ No newline at end of file From d23a19d20d512fa1d0eadbb2d740a0a564beb07a Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Mon, 25 Dec 2023 10:01:55 -0600 Subject: [PATCH 02/13] Add TopMenu Navigation --- .../IOC/AutofacRegistrationModule.cs | 4 ++ WorkoutBuilder/Models/TopMenuModel.cs | 18 +++++++ WorkoutBuilder/Program.cs | 2 +- .../ViewComponents/TopMenuViewComponent.cs | 29 +++++++++++ .../Shared/Components/TopMenu/TopMenu.cshtml | 50 +++++++++++++++++++ WorkoutBuilder/Views/Shared/_Layout.cshtml | 37 +------------- 6 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 WorkoutBuilder/Models/TopMenuModel.cs create mode 100644 WorkoutBuilder/ViewComponents/TopMenuViewComponent.cs create mode 100644 WorkoutBuilder/Views/Shared/Components/TopMenu/TopMenu.cshtml diff --git a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs index 70f2c6e..bac45b1 100644 --- a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs +++ b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs @@ -6,6 +6,7 @@ using WorkoutBuilder.Services; using WorkoutBuilder.Services.Impl; using WorkoutBuilder.Services.Impl.Helpers; +using WorkoutBuilder.ViewComponents; namespace WorkoutBuilder.IOC { @@ -56,6 +57,9 @@ protected override void Load(ContainerBuilder builder) // Controllers builder.RegisterType().PropertiesAutowired(); builder.RegisterType().PropertiesAutowired(); + + // View Components + builder.RegisterType().PropertiesAutowired(); } protected void RegisterRepository(ContainerBuilder builder) where TModel : class diff --git a/WorkoutBuilder/Models/TopMenuModel.cs b/WorkoutBuilder/Models/TopMenuModel.cs new file mode 100644 index 0000000..a38cfbc --- /dev/null +++ b/WorkoutBuilder/Models/TopMenuModel.cs @@ -0,0 +1,18 @@ +namespace WorkoutBuilder.Models +{ + public class TopMenuModel + { + public MenuItemModel Home { get; set; } + public MenuItemModel TimingCalc { get; set; } + public MenuItemModel Contact { get; set; } + public MenuItemModel? Logout { get; set; } + public MenuItemModel? Login { get; set; } + public MenuItemModel? SignUp { get; set; } + } + + public class MenuItemModel + { + public string DisplayName { get; set; } + public string Url { get; set; } + } +} diff --git a/WorkoutBuilder/Program.cs b/WorkoutBuilder/Program.cs index 9cdad31..bcb6df2 100644 --- a/WorkoutBuilder/Program.cs +++ b/WorkoutBuilder/Program.cs @@ -20,7 +20,7 @@ public static void Main(string[] args) // Add services to the container. IConfiguration configuration = builder.Configuration; - builder.Services.AddMvc().AddControllersAsServices(); + builder.Services.AddMvc().AddControllersAsServices().AddViewComponentsAsServices(); // Register AutoFac builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()) diff --git a/WorkoutBuilder/ViewComponents/TopMenuViewComponent.cs b/WorkoutBuilder/ViewComponents/TopMenuViewComponent.cs new file mode 100644 index 0000000..21ca093 --- /dev/null +++ b/WorkoutBuilder/ViewComponents/TopMenuViewComponent.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using WorkoutBuilder.Models; +using WorkoutBuilder.Services; + +namespace WorkoutBuilder.ViewComponents +{ + public class TopMenuViewComponent : ViewComponent + { + public IUserContext UserContext { init; protected get; } = null!; + public IUrlBuilder UrlBuilder { init; protected get; } = null!; + + public async Task InvokeAsync() + { + var model = new TopMenuModel(); + model.Home = new MenuItemModel { DisplayName = "Home", Url = UrlBuilder.Action("Index", "Home", null) }; + model.Contact = new MenuItemModel { DisplayName = "Contact", Url = UrlBuilder.Action("Contact", "Home", null) }; + model.TimingCalc = new MenuItemModel { DisplayName = "Timing Calc", Url = UrlBuilder.Action("Index", "Timing", null) }; + if (UserContext.GetUserId() != null) + model.Logout = new MenuItemModel { DisplayName = "Logout", Url = UrlBuilder.Action("Logout", "Users", null) }; + else + { + model.Login = new MenuItemModel { DisplayName = "Login", Url = UrlBuilder.Action("Login", "Users", null) }; + } + + await Task.CompletedTask; + return View("TopMenu", model); + } + } +} diff --git a/WorkoutBuilder/Views/Shared/Components/TopMenu/TopMenu.cshtml b/WorkoutBuilder/Views/Shared/Components/TopMenu/TopMenu.cshtml new file mode 100644 index 0000000..5e448d9 --- /dev/null +++ b/WorkoutBuilder/Views/Shared/Components/TopMenu/TopMenu.cshtml @@ -0,0 +1,50 @@ +@model TopMenuModel; + + \ No newline at end of file diff --git a/WorkoutBuilder/Views/Shared/_Layout.cshtml b/WorkoutBuilder/Views/Shared/_Layout.cshtml index 5da9c02..1497338 100644 --- a/WorkoutBuilder/Views/Shared/_Layout.cshtml +++ b/WorkoutBuilder/Views/Shared/_Layout.cshtml @@ -30,43 +30,8 @@ @await RenderSectionAsync("header", required: false) - + @await Component.InvokeAsync("TopMenu") @RenderBody() From babdf8d23e4b5ee226c89b9090ff1e542e5eda11 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Mon, 25 Dec 2023 10:12:10 -0600 Subject: [PATCH 03/13] Add Workout table --- ...0231225161038_Add-WorkoutTable.Designer.cs | 267 ++++++++++++++++++ .../20231225161038_Add-WorkoutTable.cs | 60 ++++ .../WorkoutBuilderContextModelSnapshot.cs | 45 +++ WorkoutBuilder.Data/Workout.cs | 20 ++ WorkoutBuilder.Data/WorkoutBuilderContext.cs | 7 + 5 files changed, 399 insertions(+) create mode 100644 WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.Designer.cs create mode 100644 WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.cs create mode 100644 WorkoutBuilder.Data/Workout.cs diff --git a/WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.Designer.cs b/WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.Designer.cs new file mode 100644 index 0000000..aabf907 --- /dev/null +++ b/WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.Designer.cs @@ -0,0 +1,267 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using WorkoutBuilder.Data; + +#nullable disable + +namespace WorkoutBuilder.Data.Migrations +{ + [DbContext(typeof(WorkoutBuilderContext))] + [Migration("20231225161038_Add-WorkoutTable")] + partial class AddWorkoutTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workouts") + .HasAnnotation("ProductVersion", "7.0.13") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("WorkoutBuilder.Data.Exercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Equipment") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FocusId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FocusId"); + + b.ToTable("Exercise", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Focus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("Focus", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Timing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CustomGenerator") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("StationTiming") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Stations") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.ToTable("Timing", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LockDate") + .HasColumnType("datetime2"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PasswordResetDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EmailAddress") + .IsUnique(); + + b.ToTable("User", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.UserPasswordResetRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompleteDate") + .HasColumnType("datetime2"); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("ExpireDate") + .HasColumnType("datetime2"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserPasswordResetRequest", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Workout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("IsFavorite") + .HasColumnType("bit"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Workout", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Exercise", b => + { + b.HasOne("WorkoutBuilder.Data.Focus", "Focus") + .WithMany() + .HasForeignKey("FocusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Focus"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.UserPasswordResetRequest", b => + { + b.HasOne("WorkoutBuilder.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Workout", b => + { + b.HasOne("WorkoutBuilder.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.cs b/WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.cs new file mode 100644 index 0000000..9449561 --- /dev/null +++ b/WorkoutBuilder.Data/Migrations/20231225161038_Add-WorkoutTable.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WorkoutBuilder.Data.Migrations +{ + /// + public partial class AddWorkoutTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Workout", + schema: "workouts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PublicId = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + UserId = table.Column(type: "bigint", nullable: true), + CreateDate = table.Column(type: "datetime2", nullable: false), + Body = table.Column(type: "nvarchar(max)", nullable: false), + IsFavorite = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Workout", x => x.Id); + table.ForeignKey( + name: "FK_Workout_User_UserId", + column: x => x.UserId, + principalSchema: "workouts", + principalTable: "User", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Workout_PublicId", + schema: "workouts", + table: "Workout", + column: "PublicId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Workout_UserId", + schema: "workouts", + table: "Workout", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Workout", + schema: "workouts"); + } + } +} diff --git a/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs b/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs index 2d787df..05d91b8 100644 --- a/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs +++ b/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs @@ -192,6 +192,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserPasswordResetRequest", "workouts"); }); + modelBuilder.Entity("WorkoutBuilder.Data.Workout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("IsFavorite") + .HasColumnType("bit"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Workout", "workouts"); + }); + modelBuilder.Entity("WorkoutBuilder.Data.Exercise", b => { b.HasOne("WorkoutBuilder.Data.Focus", "Focus") @@ -213,6 +249,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + + modelBuilder.Entity("WorkoutBuilder.Data.Workout", b => + { + b.HasOne("WorkoutBuilder.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); #pragma warning restore 612, 618 } } diff --git a/WorkoutBuilder.Data/Workout.cs b/WorkoutBuilder.Data/Workout.cs new file mode 100644 index 0000000..835b69d --- /dev/null +++ b/WorkoutBuilder.Data/Workout.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace WorkoutBuilder.Data +{ + [Table("Workout")] + public class Workout + { + [Key] + public long Id { get; set; } + [Required, MaxLength(255)] + public string PublicId { get; set; } + public long? UserId { get; set; } + [Required] + public DateTime CreateDate { get; set; } + public string Body { get; set; } + public bool IsFavorite { get; set; } + public virtual User User { get; set; } + } +} diff --git a/WorkoutBuilder.Data/WorkoutBuilderContext.cs b/WorkoutBuilder.Data/WorkoutBuilderContext.cs index 3d18de0..f0cb72c 100644 --- a/WorkoutBuilder.Data/WorkoutBuilderContext.cs +++ b/WorkoutBuilder.Data/WorkoutBuilderContext.cs @@ -19,11 +19,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(x => x.HasIndex(t => t.PublicId).IsUnique()); modelBuilder.Entity() .HasOne(x => x.User); + + modelBuilder.Entity() + .HasOne(x => x.User); + modelBuilder.Entity(x => x.HasIndex(t => t.PublicId).IsUnique()); + + } public DbSet Focuses { get; set; } public DbSet Exercises { get; set; } public DbSet Timings { get; set; } public DbSet Users { get; set; } + public DbSet User { get; set; } } } From eead4597a3f8e53253d58737dc32bd08ae78bf5b Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Mon, 25 Dec 2023 19:37:58 -0600 Subject: [PATCH 04/13] Save workout history --- .../WorkoutServiceFixture.cs | 129 ++++++++++++++++++ WorkoutBuilder.Services/IWorkoutService.cs | 10 ++ .../Impl/WorkoutService.cs | 53 +++++++ WorkoutBuilder/Controllers/HomeController.cs | 6 +- .../IOC/AutofacRegistrationModule.cs | 2 + 5 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs create mode 100644 WorkoutBuilder.Services/IWorkoutService.cs create mode 100644 WorkoutBuilder.Services/Impl/WorkoutService.cs diff --git a/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs b/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs new file mode 100644 index 0000000..455876b --- /dev/null +++ b/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs @@ -0,0 +1,129 @@ +using WorkoutBuilder.Data; +using WorkoutBuilder.Services.Impl; +using WorkoutBuilder.Services.Models; +using WorkoutBuilder.Services.Tests.TestUtilities; + +namespace WorkoutBuilder.Services.Tests +{ + public class WorkoutServiceFixture + { + [TestFixture] + public class When_Creating + { + [Test] + public async Task Should_Save() + { + var workoutRepo = new TestRepo(); + var userContext = A.Fake(); + A.CallTo(() => userContext.GetUserId()).Returns(1L); + + var workoutService = new WorkoutService { UserContext = userContext, WorkoutRepository = workoutRepo }; + + var id = await workoutService.Create(new WorkoutGenerationResponseModel()); + + Assert.IsNotNull(id); + var created = workoutRepo.AddedItems.Single(); + Assert.That(created.PublicId, Is.EqualTo(id)); + Assert.That(created.UserId, Is.EqualTo(1)); + Assert.That(created.CreateDate, Is.GreaterThan(DateTime.UtcNow.AddMinutes(-1))); + } + + [Test] + public async Task Should_Not_Save_Anonymous() + { + var workoutRepo = new TestRepo(); + var userContext = A.Fake(); + var workoutService = new WorkoutService { UserContext = userContext, WorkoutRepository = workoutRepo }; + + var id = await workoutService.Create(new WorkoutGenerationResponseModel()); + + Assert.IsNull(id); + Assert.That(workoutRepo.AddedItems.Count, Is.EqualTo(0)); + } + + [TestFixture] + public class When_Too_Many_Workouts_Saved + { + [Test] + public async Task Should_Delete_Saved_Workouts() + { + var workouts = Enumerable.Range(1, 101) // 101 existing records, so *2* should be deleted + .Select(x => new Workout { Id = x, UserId = 1 }); + + var workoutRepo = new TestRepo(workouts); + var userContext = A.Fake(); + A.CallTo(() => userContext.GetUserId()).Returns(1L); + + var workoutService = new WorkoutService { UserContext = userContext, WorkoutRepository = workoutRepo }; + + var id = await workoutService.Create(new WorkoutGenerationResponseModel()); + Assert.IsNotNull(id); + Assert.That(workoutRepo.AddedItems.Count, Is.EqualTo(1)); + Assert.That(workoutRepo.DeletedItems.Count, Is.EqualTo(2)); + } + + [Test] + public async Task Should_Not_Create_When_All_Favorites() + { + var workouts = Enumerable.Range(1, 100) + .Select(x => new Workout { Id = x, UserId = 1, IsFavorite = true }); + + var workoutRepo = new TestRepo(workouts); + var userContext = A.Fake(); + A.CallTo(() => userContext.GetUserId()).Returns(1L); + + var workoutService = new WorkoutService { UserContext = userContext, WorkoutRepository = workoutRepo }; + + var id = await workoutService.Create(new WorkoutGenerationResponseModel()); + Assert.IsNull(id); + Assert.That(workoutRepo.AddedItems.Count, Is.EqualTo(0)); + Assert.That(workoutRepo.DeletedItems.Count, Is.EqualTo(0)); + + } + + [Test] + public async Task Should_Not_Delete_Favorites() + { + var now = DateTime.UtcNow; + // Generate a list of 100 workouts, with the first two as "favorites" + var workouts = new List + { + new Workout { Id = 1, UserId = 1, IsFavorite = true, CreateDate = now.AddHours(-100)}, + new Workout { Id = 2, UserId = 1, IsFavorite = true, CreateDate = now.AddHours(-99)}, + new Workout { Id = 3, UserId = 1, IsFavorite = false, CreateDate = now.AddHours(-98)} // This one should be deleted + }; + workouts.AddRange(Enumerable.Range(4, 97).Select(x => new Workout { Id = x, UserId = 1, CreateDate = now.AddHours(100 - x) })); + var workoutRepo = new TestRepo(workouts); + var userContext = A.Fake(); + A.CallTo(() => userContext.GetUserId()).Returns(1L); + + var workoutService = new WorkoutService { UserContext = userContext, WorkoutRepository = workoutRepo }; + + var id = await workoutService.Create(new WorkoutGenerationResponseModel()); + Assert.IsNotNull(id); + Assert.That(workoutRepo.AddedItems.Count, Is.EqualTo(1)); + Assert.That(workoutRepo.DeletedIds.Single(), Is.EqualTo(3)); + + } + + [Test] + public async Task Should_Delete_Oldest() + { + var now = DateTime.UtcNow; + var workouts = Enumerable.Range(1, 99).Select(x => new Workout { Id = x, UserId = 1, CreateDate = now }).ToList(); + workouts.Add(new Workout { Id = 101, UserId = 1, CreateDate = now.AddDays(-100) }); // This one should be deleted + var workoutRepo = new TestRepo(workouts); + var userContext = A.Fake(); + A.CallTo(() => userContext.GetUserId()).Returns(1L); + + var workoutService = new WorkoutService { UserContext = userContext, WorkoutRepository = workoutRepo }; + + var id = await workoutService.Create(new WorkoutGenerationResponseModel()); + Assert.IsNotNull(id); + Assert.That(workoutRepo.AddedItems.Count, Is.EqualTo(1)); + Assert.That(workoutRepo.DeletedIds.Single(), Is.EqualTo(101)); + } + } + } + } +} diff --git a/WorkoutBuilder.Services/IWorkoutService.cs b/WorkoutBuilder.Services/IWorkoutService.cs new file mode 100644 index 0000000..a3e565e --- /dev/null +++ b/WorkoutBuilder.Services/IWorkoutService.cs @@ -0,0 +1,10 @@ +using WorkoutBuilder.Services.Models; + +namespace WorkoutBuilder.Services +{ + + public interface IWorkoutService + { + Task Create(WorkoutGenerationResponseModel generatedWorkout); + } +} diff --git a/WorkoutBuilder.Services/Impl/WorkoutService.cs b/WorkoutBuilder.Services/Impl/WorkoutService.cs new file mode 100644 index 0000000..f1d30fe --- /dev/null +++ b/WorkoutBuilder.Services/Impl/WorkoutService.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; +using WorkoutBuilder.Data; +using WorkoutBuilder.Services.Models; + +namespace WorkoutBuilder.Services.Impl +{ + public class WorkoutService : IWorkoutService + { + public IUserContext UserContext { init; protected get; } = null!; + public IRepository WorkoutRepository { init; protected get; } = null!; + private const int WorkoutsPerUser = 100; + + public async Task Create(WorkoutGenerationResponseModel generatedWorkout) + { + var userId = UserContext.GetUserId(); + if (userId == null) + return null; + + var counts = WorkoutRepository.GetAll().Where(x => x.UserId == userId) + .GroupBy(x => x.UserId) + .Select(x => new { Favorites = x.Where(y => y.IsFavorite).Count(), Total = x.Count() }) + .SingleOrDefault(); + + // If too many favorites; we can't delete anything. Don't save the routine. + if (counts != null && counts.Favorites >= WorkoutsPerUser) + return null; + + if(counts != null && counts.Total >= WorkoutsPerUser) + { + // Trim the list so there are only 99 saved routines + var take = counts.Total - (WorkoutsPerUser - 1); + var deletes = WorkoutRepository.GetAll().Where(x => x.UserId == userId && !x.IsFavorite) + .OrderBy(x => x.CreateDate) + .Take(take) + .ToList(); + + foreach (var delete in deletes) + await WorkoutRepository.Delete(delete); + } + + + var workout = new Workout + { + UserId = userId, + Body = JsonConvert.SerializeObject(generatedWorkout), + CreateDate = DateTime.UtcNow, + PublicId = Guid.NewGuid().ToString("n") + }; + await WorkoutRepository.Add(workout); + return workout.PublicId; + } + } +} diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index ba56ed9..14e26cf 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -18,6 +18,8 @@ public class HomeController : Controller public IConfiguration Configuration { protected get; init; } = null!; public IUserContext UserContext { protected get; init; } = null!; + public IWorkoutService WorkoutService { protected get; init; } = null!; + public IActionResult Index() { return View(); @@ -37,7 +39,7 @@ public IActionResult Equipment() return Json(equipment); } - public IActionResult Workout(string? timing, string? focus, string equipment) + public async Task Workout(string? timing, string? focus, string equipment) { Services.Models.Focus? requestedFocus = null; if (Enum.TryParse(focus, true, out var parsedFocus)) @@ -46,7 +48,9 @@ public IActionResult Workout(string? timing, string? focus, string equipment) var workoutTiming = WorkoutGeneratorFactory.GetTiming(timing); var result = WorkoutGeneratorFactory.GetGenerator(workoutTiming) .Generate(new WorkoutGenerationRequestModel { Timing = workoutTiming, Focus = requestedFocus, Equipment = equipment?.Split('|').ToList() }); + var publicId = await WorkoutService.Create(result); + Response.Headers["X-PublicId"] = publicId; return Json(result); } diff --git a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs index bac45b1..3701478 100644 --- a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs +++ b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs @@ -32,6 +32,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); + builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); if (Configuration["InjectionMode"] == "development") { @@ -53,6 +54,7 @@ protected override void Load(ContainerBuilder builder) RegisterRepository(builder); RegisterRepository(builder); RegisterRepository(builder); + RegisterRepository(builder); // Controllers builder.RegisterType().PropertiesAutowired(); From 92116a938b1792840e535d9f8a85da7428da62d7 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Tue, 26 Dec 2023 13:35:52 -0600 Subject: [PATCH 05/13] Front-end tweaks --- WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx | 13 +++++++------ WorkoutBuilder/ClientApp/src/apis/workout.tsx | 6 +++++- WorkoutBuilder/Controllers/HomeController.cs | 4 ++-- WorkoutBuilder/Models/HomeWorkoutModel.cs | 10 ++++++++++ 4 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 WorkoutBuilder/Models/HomeWorkoutModel.cs diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx index 9a6abb6..8a4491a 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import Autocomplete from "../Components/AutoComplete"; -import { Workout, getWorkout } from "../apis/workout"; +import { Workout, WorkoutRootObject, getWorkout } from "../apis/workout"; import React from "react"; import "./HomeIndex.css"; @@ -143,11 +143,12 @@ function HomeIndex() { const focusParam: string = isFocusLocked ? focus : ""; const equipmentParam: string = selectedEquipment.join("|"); getWorkout(timingParam, focusParam, equipmentParam).then( - (result: Workout) => { - setWorkout(result); - localStorage.setItem("workout", JSON.stringify(result)); - setTiming(result.name); - setFocus(result.focus); + (result: WorkoutRootObject) => { + setWorkout(result.workout); + const { workout } = result; + localStorage.setItem("workout", JSON.stringify(workout)); + setTiming(workout.name); + setFocus(workout.focus); }, // Note: it's important to handle errors here // instead of a catch() block so that we don't swallow diff --git a/WorkoutBuilder/ClientApp/src/apis/workout.tsx b/WorkoutBuilder/ClientApp/src/apis/workout.tsx index 0cdb865..9b7ca13 100644 --- a/WorkoutBuilder/ClientApp/src/apis/workout.tsx +++ b/WorkoutBuilder/ClientApp/src/apis/workout.tsx @@ -1,3 +1,7 @@ +export interface WorkoutRootObject { + publicId: null | string; + workout: Workout; +} export interface Workout { name: string; focus: string; @@ -20,7 +24,7 @@ export function getWorkout( timing: null | string, focus: null | string, equipment: null | string -): Promise { +): Promise { timing = encodeURIComponent(timing || ""); focus = encodeURIComponent(focus || ""); return fetch( diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index 14e26cf..f0ab507 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -50,8 +50,8 @@ public async Task Workout(string? timing, string? focus, string e .Generate(new WorkoutGenerationRequestModel { Timing = workoutTiming, Focus = requestedFocus, Equipment = equipment?.Split('|').ToList() }); var publicId = await WorkoutService.Create(result); - Response.Headers["X-PublicId"] = publicId; - return Json(result); + var model = new HomeWorkoutModel { PublicId = publicId, Workout = result }; + return Json(model); } public IActionResult TimingCalc() diff --git a/WorkoutBuilder/Models/HomeWorkoutModel.cs b/WorkoutBuilder/Models/HomeWorkoutModel.cs new file mode 100644 index 0000000..9cf5248 --- /dev/null +++ b/WorkoutBuilder/Models/HomeWorkoutModel.cs @@ -0,0 +1,10 @@ +using WorkoutBuilder.Services.Models; + +namespace WorkoutBuilder.Models +{ + public class HomeWorkoutModel + { + public string? PublicId { get; set; } + public WorkoutGenerationResponseModel Workout { get; set; } + } +} From 838ab7d48c8eac0ed08f9c96eb0f7f8034996f14 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Wed, 27 Dec 2023 10:08:12 -0600 Subject: [PATCH 06/13] Add User.IsAdmin, HomeWorkoutModelMapper --- ...20231227031228_Add-UserIsAdmin.Designer.cs | 270 ++++++++++++++++++ .../20231227031228_Add-UserIsAdmin.cs | 31 ++ .../WorkoutBuilderContextModelSnapshot.cs | 3 + WorkoutBuilder.Data/User.cs | 1 + WorkoutBuilder.Services/IUserContext.cs | 2 + .../Impl/AuthenticationService.cs | 16 +- .../Impl/ClaimsBasedUserContext.cs | 36 ++- .../Models/WorkoutBuilderClaimTypes.cs | 3 + WorkoutBuilder/Controllers/HomeController.cs | 8 +- .../IOC/AutofacRegistrationModule.cs | 1 + WorkoutBuilder/Models/HomeWorkoutModel.cs | 1 + .../Services/HomeWorkoutModelMapper.cs | 30 ++ 12 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.Designer.cs create mode 100644 WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.cs create mode 100644 WorkoutBuilder/Services/HomeWorkoutModelMapper.cs diff --git a/WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.Designer.cs b/WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.Designer.cs new file mode 100644 index 0000000..0d88f6c --- /dev/null +++ b/WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.Designer.cs @@ -0,0 +1,270 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using WorkoutBuilder.Data; + +#nullable disable + +namespace WorkoutBuilder.Data.Migrations +{ + [DbContext(typeof(WorkoutBuilderContext))] + [Migration("20231227031228_Add-UserIsAdmin")] + partial class AddUserIsAdmin + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workouts") + .HasAnnotation("ProductVersion", "7.0.13") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("WorkoutBuilder.Data.Exercise", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Equipment") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FocusId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FocusId"); + + b.ToTable("Exercise", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Focus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("Focus", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Timing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CustomGenerator") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("StationTiming") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Stations") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.ToTable("Timing", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsAdmin") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LockDate") + .HasColumnType("datetime2"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PasswordResetDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EmailAddress") + .IsUnique(); + + b.ToTable("User", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.UserPasswordResetRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompleteDate") + .HasColumnType("datetime2"); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("ExpireDate") + .HasColumnType("datetime2"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserPasswordResetRequest", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Workout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreateDate") + .HasColumnType("datetime2"); + + b.Property("IsFavorite") + .HasColumnType("bit"); + + b.Property("PublicId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Workout", "workouts"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Exercise", b => + { + b.HasOne("WorkoutBuilder.Data.Focus", "Focus") + .WithMany() + .HasForeignKey("FocusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Focus"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.UserPasswordResetRequest", b => + { + b.HasOne("WorkoutBuilder.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WorkoutBuilder.Data.Workout", b => + { + b.HasOne("WorkoutBuilder.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.cs b/WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.cs new file mode 100644 index 0000000..6e4c8dc --- /dev/null +++ b/WorkoutBuilder.Data/Migrations/20231227031228_Add-UserIsAdmin.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WorkoutBuilder.Data.Migrations +{ + /// + public partial class AddUserIsAdmin : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdmin", + schema: "workouts", + table: "User", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAdmin", + schema: "workouts", + table: "User"); + } + } +} diff --git a/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs b/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs index 05d91b8..0acbf54 100644 --- a/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs +++ b/WorkoutBuilder.Data/Migrations/WorkoutBuilderContextModelSnapshot.cs @@ -128,6 +128,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(255) .HasColumnType("nvarchar(255)"); + b.Property("IsAdmin") + .HasColumnType("bit"); + b.Property("LastName") .IsRequired() .HasMaxLength(255) diff --git a/WorkoutBuilder.Data/User.cs b/WorkoutBuilder.Data/User.cs index e010a58..988107a 100644 --- a/WorkoutBuilder.Data/User.cs +++ b/WorkoutBuilder.Data/User.cs @@ -23,5 +23,6 @@ public class User public DateTime CreateDate { get; set; } public DateTime? LockDate { get; set; } public DateTime? PasswordResetDate { get; set; } + public bool IsAdmin { get; set; } } } diff --git a/WorkoutBuilder.Services/IUserContext.cs b/WorkoutBuilder.Services/IUserContext.cs index a4e7512..b5a9822 100644 --- a/WorkoutBuilder.Services/IUserContext.cs +++ b/WorkoutBuilder.Services/IUserContext.cs @@ -4,5 +4,7 @@ public interface IUserContext { long? GetUserId(); string? GetEmailAddress(); + bool CanManageWorkout(long? userId); + bool CanManageWorkoutFavorite(long? userId); } } diff --git a/WorkoutBuilder.Services/Impl/AuthenticationService.cs b/WorkoutBuilder.Services/Impl/AuthenticationService.cs index 8319847..862911b 100644 --- a/WorkoutBuilder.Services/Impl/AuthenticationService.cs +++ b/WorkoutBuilder.Services/Impl/AuthenticationService.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using WorkoutBuilder.Data; using WorkoutBuilder.Services.Models; +using ClaimTypes = WorkoutBuilder.Services.Models.WorkoutBuilderClaimTypes; namespace WorkoutBuilder.Services.Impl { @@ -30,11 +31,20 @@ public async Task Login(string username, string password) private List GetClaims(User user) { - return new List + var output = new List { - new Claim(ClaimTypes.Email, user.EmailAddress), - new Claim(WorkoutBuilderClaimTypes.Id, user.Id.ToString()) + new Claim(System.Security.Claims.ClaimTypes.Email, user.EmailAddress), + new Claim(ClaimTypes.Id, user.Id.ToString()), + new Claim($"{ClaimTypes.Users}/{user.Id}{ClaimTypes.Workouts}{ClaimTypes.Manage}", string.Empty), + new Claim($"{ClaimTypes.Users}/{user.Id}{ClaimTypes.Workouts}{ClaimTypes.Favorites}{ClaimTypes.Manage}", string.Empty) }; + + if(user.IsAdmin) + { + output.Add(new Claim($"{ClaimTypes.Users}/All{ClaimTypes.Workouts}{ClaimTypes.Manage}", string.Empty)); + } + + return output; } } } diff --git a/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs b/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs index abc2cb7..b77dc94 100644 --- a/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs +++ b/WorkoutBuilder.Services/Impl/ClaimsBasedUserContext.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using System.Security.Claims; using WorkoutBuilder.Services.Models; +using ClaimTypes = WorkoutBuilder.Services.Models.WorkoutBuilderClaimTypes; namespace WorkoutBuilder.Services.Impl { @@ -8,18 +9,49 @@ public class ClaimsBasedUserContext : IUserContext { public IHttpContextAccessor HttpContextAccessor { init; protected get; } = null!; - public string? GetEmailAddress() => User?.FindFirst(ClaimTypes.Email)?.Value; + public string? GetEmailAddress() => User?.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; public long? GetUserId() { if (User != null) { - if (long.TryParse(User.FindFirst(WorkoutBuilderClaimTypes.Id)?.Value, out var id)) + if (long.TryParse(User.FindFirst(ClaimTypes.Id)?.Value, out var id)) return id; } return null; } + public bool CanManageWorkout(long? userId) + { + if (User == null) + return false; + + if (User.HasClaim(x => x.Type == $"{ClaimTypes.Users}/All{ClaimTypes.Workouts}{ClaimTypes.Manage}")) + return true; + + if (userId.HasValue) + { + if (User.HasClaim(x => x.Type == $"{ClaimTypes.Users}/{userId.Value}{ClaimTypes.Workouts}{ClaimTypes.Manage}")) + return true; + } + + return false; + + } + public bool CanManageWorkoutFavorite(long? userId) + { + if (User == null) + return false; + + if (userId.HasValue) + { + if (User.HasClaim(x => x.Type == $"{ClaimTypes.Users}/{userId.Value}{ClaimTypes.Workouts}{ClaimTypes.Favorites}{ClaimTypes.Manage}")) + return true; + } + + return false; + } + protected ClaimsPrincipal? User => HttpContextAccessor.HttpContext.User; } } diff --git a/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs b/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs index 08f096b..4c27d0e 100644 --- a/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs +++ b/WorkoutBuilder.Services/Models/WorkoutBuilderClaimTypes.cs @@ -3,6 +3,9 @@ public class WorkoutBuilderClaimTypes { public const string Id = "http://workoutbuild.com/2023/12/identity/claims/Id"; + public const string Users = "http://workoutbuild.com/2023/12/users"; + public const string Workouts = "/workouts"; + public const string Favorites = "/favorites"; public const string Manage = "/Manage"; public const string Read = "/Read"; diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index f0ab507..05b7b78 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -16,9 +16,7 @@ public class HomeController : Controller public IWorkoutGeneratorFactory WorkoutGeneratorFactory { protected get; init; } = null!; public IEmailService EmailService { protected get; init; } = null!; public IConfiguration Configuration { protected get; init; } = null!; - public IUserContext UserContext { protected get; init; } = null!; - - public IWorkoutService WorkoutService { protected get; init; } = null!; + public IHomeWorkoutModelMapper HomeWorkoutModelMapper { protected get; init; } = null!; public IActionResult Index() { @@ -48,9 +46,7 @@ public async Task Workout(string? timing, string? focus, string e var workoutTiming = WorkoutGeneratorFactory.GetTiming(timing); var result = WorkoutGeneratorFactory.GetGenerator(workoutTiming) .Generate(new WorkoutGenerationRequestModel { Timing = workoutTiming, Focus = requestedFocus, Equipment = equipment?.Split('|').ToList() }); - var publicId = await WorkoutService.Create(result); - - var model = new HomeWorkoutModel { PublicId = publicId, Workout = result }; + var model = await HomeWorkoutModelMapper.Map(result); return Json(model); } diff --git a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs index 3701478..fe0c3d1 100644 --- a/WorkoutBuilder/IOC/AutofacRegistrationModule.cs +++ b/WorkoutBuilder/IOC/AutofacRegistrationModule.cs @@ -33,6 +33,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); + builder.RegisterType().As().PropertiesAutowired().InstancePerLifetimeScope(); if (Configuration["InjectionMode"] == "development") { diff --git a/WorkoutBuilder/Models/HomeWorkoutModel.cs b/WorkoutBuilder/Models/HomeWorkoutModel.cs index 9cf5248..a0ae122 100644 --- a/WorkoutBuilder/Models/HomeWorkoutModel.cs +++ b/WorkoutBuilder/Models/HomeWorkoutModel.cs @@ -6,5 +6,6 @@ public class HomeWorkoutModel { public string? PublicId { get; set; } public WorkoutGenerationResponseModel Workout { get; set; } + public List Permissions { get; set; } } } diff --git a/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs b/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs new file mode 100644 index 0000000..4313416 --- /dev/null +++ b/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs @@ -0,0 +1,30 @@ +using WorkoutBuilder.Models; +using WorkoutBuilder.Services.Models; + +namespace WorkoutBuilder.Services +{ + public interface IHomeWorkoutModelMapper + { + Task Map(WorkoutGenerationResponseModel data); + } + public class HomeWorkoutModelMapper : IHomeWorkoutModelMapper + { + public IWorkoutService WorkoutService { init; protected get; } = null!; + public IUserContext UserContext { init; protected get; } = null!; + public async Task Map(WorkoutGenerationResponseModel data) + { + var publicId = await WorkoutService.Create(data); + + var model = new HomeWorkoutModel { + PublicId = publicId, + Workout = data, + Permissions = new List() + }; + + if (UserContext.GetUserId().HasValue) + model.Permissions.Add("favorite"); + + return model; + } + } +} From f412761c9bb38c5c5afb601e9de6d9731cc3cdd9 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Thu, 28 Dec 2023 13:47:30 -0600 Subject: [PATCH 07/13] Implement tooltips, copy link button --- WorkoutBuilder/ClientApp/package-lock.json | 6 + WorkoutBuilder/ClientApp/package.json | 1 + .../ClientApp/src/Pages/HomeIndex.css | 12 +- .../ClientApp/src/Pages/HomeIndex.tsx | 107 ++++++++++++++++-- WorkoutBuilder/ClientApp/src/apis/workout.tsx | 11 +- WorkoutBuilder/Controllers/HomeController.cs | 12 +- .../Services/HomeWorkoutModelMapper.cs | 23 +++- 7 files changed, 150 insertions(+), 22 deletions(-) diff --git a/WorkoutBuilder/ClientApp/package-lock.json b/WorkoutBuilder/ClientApp/package-lock.json index 88912c5..70a6407 100644 --- a/WorkoutBuilder/ClientApp/package-lock.json +++ b/WorkoutBuilder/ClientApp/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@creativebulma/bulma-tooltip": "^1.2.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -387,6 +388,11 @@ "node": ">=6.9.0" } }, + "node_modules/@creativebulma/bulma-tooltip": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@creativebulma/bulma-tooltip/-/bulma-tooltip-1.2.0.tgz", + "integrity": "sha512-ooImbeXEBxf77cttbzA7X5rC5aAWm9UsXIGViFOnsqB+6M944GkB28S5R4UWRqjFd2iW4zGEkEifAU+q43pt2w==" + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", diff --git a/WorkoutBuilder/ClientApp/package.json b/WorkoutBuilder/ClientApp/package.json index a3aa242..6f6a160 100644 --- a/WorkoutBuilder/ClientApp/package.json +++ b/WorkoutBuilder/ClientApp/package.json @@ -15,6 +15,7 @@ "author": "", "license": "ISC", "dependencies": { + "@creativebulma/bulma-tooltip": "^1.2.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.css b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.css index 7cfe612..10842cf 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.css +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.css @@ -1,8 +1,7 @@ .is-pulse { animation: pulse 1.5s infinite; - border: 2px rgb(72, 199, 116) solid; - - box-shadow: 0 0 0 0 rgba(72, 199, 116, 0.5); + border: 2px rgb(246, 101, 101) solid; + box-shadow: 0 0 0 0 rgba(246, 101, 101, 0.5); } .is-pulse:hover { -webkit-animation: none; @@ -11,12 +10,9 @@ @keyframes pulse { 0% { - -webkit-box-shadow: 0 0 0 0 rgba(72, 199, 116, 0.4); - } - 70% { - -webkit-box-shadow: 0 0 0 10px rgba(72, 199, 116, 0.2); + box-shadow: 0 0 0 0 rgba(246, 101, 101, 0.4); } 100% { - -webkit-box-shadow: 0 0 0 10px rgba(72, 199, 116, 0); + box-shadow: 0 0 0 15px rgba(246, 101, 101, 0); } } diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx index 8a4491a..027394a 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx @@ -1,7 +1,13 @@ import { useState, useEffect } from "react"; import Autocomplete from "../Components/AutoComplete"; -import { Workout, WorkoutRootObject, getWorkout } from "../apis/workout"; +import { + Workout, + WorkoutRootObject, + getWorkout, + getWorkoutById, +} from "../apis/workout"; import React from "react"; +import "@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css"; import "./HomeIndex.css"; interface Timing { @@ -19,12 +25,14 @@ function HomeIndex() { const [isFocusLocked, setIsFocusLocked] = useState(false); const [isTimingLocked, setIsTimingLocked] = useState(false); const [isAdvancedModalShown, setIsAdvancedModalShown] = useState(false); + const [lastClick, setLastClick] = useState(""); const [timings, setTimings] = useState([]); const [allEquipment, setAllEquipment] = useState([]); const [selectedEquipment, setSelectedEquipment] = useState([]); const [equipmentPreset, setEquipmentPreset] = useState("All"); const [, setError] = useState(null); const [workout, setWorkout] = useState(null); + const [publicId, setPublicId] = useState(null); useEffect(() => { fetch("/Home/Timings") @@ -53,14 +61,23 @@ function HomeIndex() { }, []); useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + + if (urlParams.has("id")) { + getSavedWorkout(urlParams.get("id") || ""); + return; + } + try { - const savedWorkout: Workout = JSON.parse( + const savedWorkout: WorkoutRootObject = JSON.parse( localStorage.getItem("workout") || "" ); if (savedWorkout.version === "v1") { - setWorkout(savedWorkout); - setTiming(savedWorkout.name); - setFocus(savedWorkout.focus); + const { workout } = savedWorkout; + setWorkout(workout); + setTiming(workout.name); + setFocus(workout.focus); + setPublicId(savedWorkout.publicId); } } catch (err) { console.error( @@ -144,11 +161,31 @@ function HomeIndex() { const equipmentParam: string = selectedEquipment.join("|"); getWorkout(timingParam, focusParam, equipmentParam).then( (result: WorkoutRootObject) => { - setWorkout(result.workout); const { workout } = result; - localStorage.setItem("workout", JSON.stringify(workout)); + setWorkout(workout); + setTiming(workout.name); + setFocus(workout.focus); + setPublicId(result.publicId); + localStorage.setItem("workout", JSON.stringify(result)); + }, + // Note: it's important to handle errors here + // instead of a catch() block so that we don't swallow + // exceptions from actual bugs in components. + (error) => { + setError(error); + } + ); + }; + + const getSavedWorkout = (id: string) => { + getWorkoutById(id).then( + (result: WorkoutRootObject) => { + const { workout } = result; + setWorkout(workout); setTiming(workout.name); setFocus(workout.focus); + setPublicId(result.publicId); + localStorage.setItem("workout", JSON.stringify(result)); }, // Note: it's important to handle errors here // instead of a catch() block so that we don't swallow @@ -172,6 +209,23 @@ function HomeIndex() { setIsAdvancedModalShown((old) => !old); }; + const setFavorite = () => { + setLastClick("favorite"); + }; + + const copyLink = () => { + navigator.clipboard + .writeText(`${window.location.origin}?id=${publicId}`) + .then( + () => { + setLastClick("copy"); + }, + () => { + console.error("Failed to copy"); + } + ); + }; + return ( <>
@@ -244,8 +298,43 @@ function HomeIndex() { {workout?.notes && (

Note: {workout.notes}

)} -

- Advanced Options +

+ {publicId && ( + <> + setLastClick("")} + > + + star + + + setLastClick("")} + > + + link + + + + )} + + + settings + +

diff --git a/WorkoutBuilder/ClientApp/src/apis/workout.tsx b/WorkoutBuilder/ClientApp/src/apis/workout.tsx index 9b7ca13..52e701a 100644 --- a/WorkoutBuilder/ClientApp/src/apis/workout.tsx +++ b/WorkoutBuilder/ClientApp/src/apis/workout.tsx @@ -1,6 +1,7 @@ export interface WorkoutRootObject { publicId: null | string; workout: Workout; + version: string; } export interface Workout { name: string; @@ -9,7 +10,6 @@ export interface Workout { timing: string; notes: null | string; exercises: Exercise[]; - version: string; } export interface Exercise { @@ -36,3 +36,12 @@ export function getWorkout( return res; }); } + +export function getWorkoutById(id: string): Promise { + return fetch(`/Home/Workout?id=${id}`) + .then((res) => res.json()) + .then((res) => { + res.version = "v1"; + return res; + }); +} diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index 05b7b78..b98ac74 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -13,6 +13,7 @@ public class HomeController : Controller { public IRepository ExerciseRepository { protected get; init; } = null!; public IRepository TimingRepository { protected get; init; } = null!; + public IRepository WorkoutRepository { protected get; init; } = null!; public IWorkoutGeneratorFactory WorkoutGeneratorFactory { protected get; init; } = null!; public IEmailService EmailService { protected get; init; } = null!; public IConfiguration Configuration { protected get; init; } = null!; @@ -37,8 +38,17 @@ public IActionResult Equipment() return Json(equipment); } - public async Task Workout(string? timing, string? focus, string equipment) + public async Task Workout(string? id, string? timing, string? focus, string equipment) { + if (id != null) + { + var savedWorkout = WorkoutRepository.GetAll().SingleOrDefault(x => x.PublicId == id); + if (savedWorkout == null) + return NotFound(); + var output = HomeWorkoutModelMapper.Map(savedWorkout, id); + return Json(output); + } + Services.Models.Focus? requestedFocus = null; if (Enum.TryParse(focus, true, out var parsedFocus)) requestedFocus = parsedFocus; diff --git a/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs b/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs index 4313416..ab7e80a 100644 --- a/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs +++ b/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs @@ -1,4 +1,6 @@ -using WorkoutBuilder.Models; +using Newtonsoft.Json; +using WorkoutBuilder.Data; +using WorkoutBuilder.Models; using WorkoutBuilder.Services.Models; namespace WorkoutBuilder.Services @@ -6,6 +8,7 @@ namespace WorkoutBuilder.Services public interface IHomeWorkoutModelMapper { Task Map(WorkoutGenerationResponseModel data); + HomeWorkoutModel Map(Workout workout, string publicId); } public class HomeWorkoutModelMapper : IHomeWorkoutModelMapper { @@ -15,8 +18,9 @@ public async Task Map(WorkoutGenerationResponseModel data) { var publicId = await WorkoutService.Create(data); - var model = new HomeWorkoutModel { - PublicId = publicId, + var model = new HomeWorkoutModel + { + PublicId = publicId, Workout = data, Permissions = new List() }; @@ -26,5 +30,18 @@ public async Task Map(WorkoutGenerationResponseModel data) return model; } + + public HomeWorkoutModel Map(Workout workout, string publicId) + { + var output = new HomeWorkoutModel + { + Workout = JsonConvert.DeserializeObject(workout.Body), + PublicId = publicId, + Permissions = new List() + }; + if (UserContext.GetUserId().HasValue) + output.Permissions.Add("favorite"); + return output; + } } } From eb9a5fb95a1fc12143187eb07a483ee333fdfbae Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Thu, 28 Dec 2023 15:23:20 -0600 Subject: [PATCH 08/13] Implement ToggleFavorite --- WorkoutBuilder.Services/IWorkoutService.cs | 1 + .../Impl/WorkoutService.cs | 30 +++++++++++++++++++ .../ClientApp/src/Pages/HomeIndex.tsx | 19 +++++++++--- WorkoutBuilder/Controllers/HomeController.cs | 12 ++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/WorkoutBuilder.Services/IWorkoutService.cs b/WorkoutBuilder.Services/IWorkoutService.cs index a3e565e..8c0b0a4 100644 --- a/WorkoutBuilder.Services/IWorkoutService.cs +++ b/WorkoutBuilder.Services/IWorkoutService.cs @@ -6,5 +6,6 @@ namespace WorkoutBuilder.Services public interface IWorkoutService { Task Create(WorkoutGenerationResponseModel generatedWorkout); + Task ToggleFavorite(string publicId); } } diff --git a/WorkoutBuilder.Services/Impl/WorkoutService.cs b/WorkoutBuilder.Services/Impl/WorkoutService.cs index f1d30fe..29b9497 100644 --- a/WorkoutBuilder.Services/Impl/WorkoutService.cs +++ b/WorkoutBuilder.Services/Impl/WorkoutService.cs @@ -49,5 +49,35 @@ public class WorkoutService : IWorkoutService await WorkoutRepository.Add(workout); return workout.PublicId; } + + public async Task ToggleFavorite(string publicId) + { + var workout = WorkoutRepository.GetAll().SingleOrDefault(x => x.PublicId == publicId); + if (workout == null) + throw new ArgumentException("Cannot add favorite: unknown public id"); + + if (UserContext.GetUserId() == null) + throw new ArgumentException("Cannot add favorite: invalid user id"); + + if(UserContext.GetUserId().Value != workout.UserId) + { + var newWorkout = new Workout + { + Body = workout.Body, + CreateDate = DateTime.UtcNow, + IsFavorite = true, + PublicId = Guid.NewGuid().ToString("n"), + UserId = UserContext.GetUserId() + }; + await WorkoutRepository.Add(newWorkout); + return newWorkout.PublicId; + } + else + { + workout.IsFavorite = !workout.IsFavorite; + await WorkoutRepository.Update(workout); + return workout.PublicId; + } + } } } diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx index 027394a..94ecd47 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx @@ -196,6 +196,16 @@ function HomeIndex() { ); }; + const setFavorite = () => { + fetch(`/Home/Favorite/${publicId}`, { + method: "POST", + }).then((res) => { + if (res.headers.has("Location")) + window.location.href = res.headers.get("Location") || ""; + else return res.json(); + }); + }; + const equipment: string[] = []; function uniqueEquipment(item: string) { if (equipment.indexOf(item) == -1) { @@ -209,11 +219,12 @@ function HomeIndex() { setIsAdvancedModalShown((old) => !old); }; - const setFavorite = () => { + const handleFavoriteClick = () => { + setFavorite(); setLastClick("favorite"); }; - const copyLink = () => { + const handleCopyClick = () => { navigator.clipboard .writeText(`${window.location.origin}?id=${publicId}`) .then( @@ -303,7 +314,7 @@ function HomeIndex() { <> setLastClick("")} diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index b98ac74..523f43b 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -5,6 +5,7 @@ using WorkoutBuilder.Data; using WorkoutBuilder.Models; using WorkoutBuilder.Services; +using WorkoutBuilder.Services.Impl; using WorkoutBuilder.Services.Models; namespace WorkoutBuilder.Controllers @@ -18,6 +19,8 @@ public class HomeController : Controller public IEmailService EmailService { protected get; init; } = null!; public IConfiguration Configuration { protected get; init; } = null!; public IHomeWorkoutModelMapper HomeWorkoutModelMapper { protected get; init; } = null!; + public IWorkoutService WorkoutService { protected get; init; } = null!; + public IUrlBuilder UrlBuilder { protected get; init; } = null!; public IActionResult Index() { @@ -90,6 +93,15 @@ public IActionResult Contact(HomeContactRequestModel data) return View(new HomeContactRequestModel()); } + [HttpPost] + public async Task Favorite(string id) + { + var newId = await WorkoutService.ToggleFavorite(id); + if (newId != id) + Response.Headers.Add("Location", UrlBuilder.Action("Index", "Home", new { id = newId })); + return Json(new { success = true }); + } + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult Error() { From cc800027e1ec6386a2acc4856ec6aae31ce88983 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Fri, 29 Dec 2023 05:11:52 -0600 Subject: [PATCH 09/13] Progress save --- WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx | 15 +++++++++++++-- WorkoutBuilder/ClientApp/src/sass/site.scss | 9 +++++++++ WorkoutBuilder/Views/Shared/_Layout.cshtml | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx index 94ecd47..5385405 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx @@ -33,6 +33,7 @@ function HomeIndex() { const [, setError] = useState(null); const [workout, setWorkout] = useState(null); const [publicId, setPublicId] = useState(null); + const [isFavorite, setIsFavorite] = useState(false); useEffect(() => { fetch("/Home/Timings") @@ -199,10 +200,14 @@ function HomeIndex() { const setFavorite = () => { fetch(`/Home/Favorite/${publicId}`, { method: "POST", + credentials: "include", }).then((res) => { if (res.headers.has("Location")) window.location.href = res.headers.get("Location") || ""; - else return res.json(); + else { + setIsFavorite(!isFavorite); + return res.json(); + } }); }; @@ -321,7 +326,13 @@ function HomeIndex() { onMouseOut={() => setLastClick("")} > - star + + star + + @await RenderSectionAsync("header", required: false) From 89a2c9dfd94d9a372a91c83ac3e9ec5e4df0e873 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Fri, 29 Dec 2023 06:31:44 -0600 Subject: [PATCH 10/13] Cleanup --- .../WorkoutServiceFixture.cs | 2 +- WorkoutBuilder.Services/IWorkoutService.cs | 5 +++-- .../Impl/WorkoutService.cs | 8 ++++---- .../ClientApp/src/Pages/HomeIndex.tsx | 20 +++++++++++-------- WorkoutBuilder/ClientApp/src/apis/workout.tsx | 1 + WorkoutBuilder/Controllers/HomeController.cs | 8 ++++---- WorkoutBuilder/Models/HomeWorkoutModel.cs | 2 +- .../Services/HomeWorkoutModelMapper.cs | 11 +++------- 8 files changed, 29 insertions(+), 28 deletions(-) diff --git a/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs b/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs index 455876b..1295a19 100644 --- a/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs +++ b/WorkoutBuilder.Services.Tests/WorkoutServiceFixture.cs @@ -8,7 +8,7 @@ namespace WorkoutBuilder.Services.Tests public class WorkoutServiceFixture { [TestFixture] - public class When_Creating + public class When_Creating_Workout { [Test] public async Task Should_Save() diff --git a/WorkoutBuilder.Services/IWorkoutService.cs b/WorkoutBuilder.Services/IWorkoutService.cs index 8c0b0a4..2dca27c 100644 --- a/WorkoutBuilder.Services/IWorkoutService.cs +++ b/WorkoutBuilder.Services/IWorkoutService.cs @@ -1,4 +1,5 @@ -using WorkoutBuilder.Services.Models; +using WorkoutBuilder.Data; +using WorkoutBuilder.Services.Models; namespace WorkoutBuilder.Services { @@ -6,6 +7,6 @@ namespace WorkoutBuilder.Services public interface IWorkoutService { Task Create(WorkoutGenerationResponseModel generatedWorkout); - Task ToggleFavorite(string publicId); + Task ToggleFavorite(string publicId); } } diff --git a/WorkoutBuilder.Services/Impl/WorkoutService.cs b/WorkoutBuilder.Services/Impl/WorkoutService.cs index 29b9497..69cfd5f 100644 --- a/WorkoutBuilder.Services/Impl/WorkoutService.cs +++ b/WorkoutBuilder.Services/Impl/WorkoutService.cs @@ -50,7 +50,7 @@ public class WorkoutService : IWorkoutService return workout.PublicId; } - public async Task ToggleFavorite(string publicId) + public async Task ToggleFavorite(string publicId) { var workout = WorkoutRepository.GetAll().SingleOrDefault(x => x.PublicId == publicId); if (workout == null) @@ -59,7 +59,7 @@ public async Task ToggleFavorite(string publicId) if (UserContext.GetUserId() == null) throw new ArgumentException("Cannot add favorite: invalid user id"); - if(UserContext.GetUserId().Value != workout.UserId) + if(!UserContext.CanManageWorkoutFavorite(workout.UserId)) { var newWorkout = new Workout { @@ -70,13 +70,13 @@ public async Task ToggleFavorite(string publicId) UserId = UserContext.GetUserId() }; await WorkoutRepository.Add(newWorkout); - return newWorkout.PublicId; + return newWorkout; } else { workout.IsFavorite = !workout.IsFavorite; await WorkoutRepository.Update(workout); - return workout.PublicId; + return workout; } } } diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx index 5385405..bb6f26e 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx @@ -186,6 +186,7 @@ function HomeIndex() { setTiming(workout.name); setFocus(workout.focus); setPublicId(result.publicId); + setIsFavorite(result.isFavorite); localStorage.setItem("workout", JSON.stringify(result)); }, // Note: it's important to handle errors here @@ -201,14 +202,17 @@ function HomeIndex() { fetch(`/Home/Favorite/${publicId}`, { method: "POST", credentials: "include", - }).then((res) => { - if (res.headers.has("Location")) - window.location.href = res.headers.get("Location") || ""; - else { - setIsFavorite(!isFavorite); - return res.json(); - } - }); + }) + .then((res) => { + if (res.headers.has("Location")) + window.location.href = res.headers.get("Location") || ""; + else { + return res.json(); + } + }) + .then((json) => { + setIsFavorite(json.isFavorite); + }); }; const equipment: string[] = []; diff --git a/WorkoutBuilder/ClientApp/src/apis/workout.tsx b/WorkoutBuilder/ClientApp/src/apis/workout.tsx index 52e701a..041cd13 100644 --- a/WorkoutBuilder/ClientApp/src/apis/workout.tsx +++ b/WorkoutBuilder/ClientApp/src/apis/workout.tsx @@ -2,6 +2,7 @@ export interface WorkoutRootObject { publicId: null | string; workout: Workout; version: string; + isFavorite: boolean; } export interface Workout { name: string; diff --git a/WorkoutBuilder/Controllers/HomeController.cs b/WorkoutBuilder/Controllers/HomeController.cs index 523f43b..f2c2c9f 100644 --- a/WorkoutBuilder/Controllers/HomeController.cs +++ b/WorkoutBuilder/Controllers/HomeController.cs @@ -96,10 +96,10 @@ public IActionResult Contact(HomeContactRequestModel data) [HttpPost] public async Task Favorite(string id) { - var newId = await WorkoutService.ToggleFavorite(id); - if (newId != id) - Response.Headers.Add("Location", UrlBuilder.Action("Index", "Home", new { id = newId })); - return Json(new { success = true }); + var workout = await WorkoutService.ToggleFavorite(id); + if (workout != null && workout.PublicId != id) + Response.Headers.Add("Location", UrlBuilder.Action("Index", "Home", new { id = workout.PublicId })); + return Json(new { success = true, workout.IsFavorite }); } [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] diff --git a/WorkoutBuilder/Models/HomeWorkoutModel.cs b/WorkoutBuilder/Models/HomeWorkoutModel.cs index a0ae122..ef37067 100644 --- a/WorkoutBuilder/Models/HomeWorkoutModel.cs +++ b/WorkoutBuilder/Models/HomeWorkoutModel.cs @@ -6,6 +6,6 @@ public class HomeWorkoutModel { public string? PublicId { get; set; } public WorkoutGenerationResponseModel Workout { get; set; } - public List Permissions { get; set; } + public bool IsFavorite { get; set; } } } diff --git a/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs b/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs index ab7e80a..6d0ea79 100644 --- a/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs +++ b/WorkoutBuilder/Services/HomeWorkoutModelMapper.cs @@ -21,13 +21,9 @@ public async Task Map(WorkoutGenerationResponseModel data) var model = new HomeWorkoutModel { PublicId = publicId, - Workout = data, - Permissions = new List() + Workout = data }; - if (UserContext.GetUserId().HasValue) - model.Permissions.Add("favorite"); - return model; } @@ -37,10 +33,9 @@ public HomeWorkoutModel Map(Workout workout, string publicId) { Workout = JsonConvert.DeserializeObject(workout.Body), PublicId = publicId, - Permissions = new List() + IsFavorite = UserContext.GetUserId() == workout.UserId && workout.IsFavorite }; - if (UserContext.GetUserId().HasValue) - output.Permissions.Add("favorite"); + return output; } } From a885b76c1c93cfc83a82c42e11a10758410109e7 Mon Sep 17 00:00:00 2001 From: Mike Devlin Date: Fri, 29 Dec 2023 18:09:13 -0600 Subject: [PATCH 11/13] Front end refactor: reduce the useState calls --- .../ClientApp/src/Pages/HomeIndex.tsx | 278 +++++++++++------- 1 file changed, 169 insertions(+), 109 deletions(-) diff --git a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx index bb6f26e..5f51b8e 100644 --- a/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx +++ b/WorkoutBuilder/ClientApp/src/Pages/HomeIndex.tsx @@ -1,11 +1,6 @@ import { useState, useEffect } from "react"; import Autocomplete from "../Components/AutoComplete"; -import { - Workout, - WorkoutRootObject, - getWorkout, - getWorkoutById, -} from "../apis/workout"; +import { WorkoutRootObject, getWorkout, getWorkoutById } from "../apis/workout"; import React from "react"; import "@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css"; import "./HomeIndex.css"; @@ -19,21 +14,34 @@ interface Timing { customGenerator: string | null; } +interface UiElements { + isFocusLocked: boolean; + isTimingLocked: boolean; + isAdvancedModalShown: boolean; + isFavorite: boolean; + lastClick: string; + timing: string; + focus: string; + selectedEquipment: string[]; + equipmentPreset: string; +} + function HomeIndex() { - const [timing, setTiming] = useState(""); - const [focus, setFocus] = useState("Hybrid"); - const [isFocusLocked, setIsFocusLocked] = useState(false); - const [isTimingLocked, setIsTimingLocked] = useState(false); - const [isAdvancedModalShown, setIsAdvancedModalShown] = useState(false); - const [lastClick, setLastClick] = useState(""); + const [uiElements, setUiElements] = useState({ + isAdvancedModalShown: false, + isFavorite: false, + isFocusLocked: false, + isTimingLocked: false, + lastClick: "", + timing: "", + focus: "Hybrid", + selectedEquipment: [], + equipmentPreset: "All", + }); const [timings, setTimings] = useState([]); const [allEquipment, setAllEquipment] = useState([]); - const [selectedEquipment, setSelectedEquipment] = useState([]); - const [equipmentPreset, setEquipmentPreset] = useState("All"); const [, setError] = useState(null); - const [workout, setWorkout] = useState(null); - const [publicId, setPublicId] = useState(null); - const [isFavorite, setIsFavorite] = useState(false); + const [workout, setWorkout] = useState(null); useEffect(() => { fetch("/Home/Timings") @@ -57,7 +65,7 @@ function HomeIndex() { .then((res) => res.json()) .then((result: string[]) => { setAllEquipment(result); - setSelectedEquipment(result); + setUiElements((x) => ({ ...x, selectedEquipment: result })); }); }, []); @@ -75,10 +83,12 @@ function HomeIndex() { ); if (savedWorkout.version === "v1") { const { workout } = savedWorkout; - setWorkout(workout); - setTiming(workout.name); - setFocus(workout.focus); - setPublicId(savedWorkout.publicId); + setWorkout(savedWorkout); + setUiElements((x) => ({ + ...x, + timing: workout.name, + focus: workout.focus, + })); } } catch (err) { console.error( @@ -89,7 +99,10 @@ function HomeIndex() { }, []); useEffect(() => { - switch (equipmentPreset) { + const setSelectedEquipment = (data: string[]) => { + setUiElements((x) => ({ ...x, selectedEquipment: data })); + }; + switch (uiElements.equipmentPreset) { case "None": setSelectedEquipment([]); break; @@ -132,41 +145,48 @@ function HomeIndex() { setSelectedEquipment(allEquipment); break; } - }, [equipmentPreset]); + }, [uiElements.equipmentPreset]); const handleTimingChange = (item: string) => { setWorkout(null); - setTiming(item); - setIsTimingLocked(true); + setUiElements((x) => ({ ...x, isTimingLocked: true, timing: item })); }; const handleFocusChange = (e: React.ChangeEvent) => { setWorkout(null); - setFocus(e.target.value); - setIsFocusLocked(true); + setUiElements((x) => ({ + ...x, + isFocusLocked: true, + focus: e.target.value, + })); }; const handleEquipmentToggle = (label: string): void => { - const selected: string[] = [...selectedEquipment]; + const selected: string[] = [...uiElements.selectedEquipment]; if (selected.includes(label)) { selected.splice(selected.indexOf(label), 1); } else { selected.push(label); } - setSelectedEquipment(selected); + setUiElements((x) => ({ ...x, selectedEquipment: selected })); }; const getCustomizedWorkout = () => { - const timingParam: string = isTimingLocked ? timing : ""; - const focusParam: string = isFocusLocked ? focus : ""; - const equipmentParam: string = selectedEquipment.join("|"); + const timingParam: string = uiElements.isTimingLocked + ? uiElements.timing + : ""; + const focusParam: string = uiElements.isFocusLocked ? uiElements.focus : ""; + const equipmentParam: string = uiElements.selectedEquipment.join("|"); getWorkout(timingParam, focusParam, equipmentParam).then( (result: WorkoutRootObject) => { const { workout } = result; - setWorkout(workout); - setTiming(workout.name); - setFocus(workout.focus); - setPublicId(result.publicId); + setWorkout(result); + setUiElements((x) => ({ + ...x, + timing: workout.name, + focus: workout.focus, + isFavorite: result.isFavorite, + })); localStorage.setItem("workout", JSON.stringify(result)); }, // Note: it's important to handle errors here @@ -182,11 +202,13 @@ function HomeIndex() { getWorkoutById(id).then( (result: WorkoutRootObject) => { const { workout } = result; - setWorkout(workout); - setTiming(workout.name); - setFocus(workout.focus); - setPublicId(result.publicId); - setIsFavorite(result.isFavorite); + setWorkout(result); + setUiElements((x) => ({ + ...x, + isFavorite: result.isFavorite, + timing: workout.name, + focus: workout.focus, + })); localStorage.setItem("workout", JSON.stringify(result)); }, // Note: it's important to handle errors here @@ -199,20 +221,22 @@ function HomeIndex() { }; const setFavorite = () => { - fetch(`/Home/Favorite/${publicId}`, { - method: "POST", - credentials: "include", - }) - .then((res) => { - if (res.headers.has("Location")) - window.location.href = res.headers.get("Location") || ""; - else { - return res.json(); - } + if (!!workout) { + fetch(`/Home/Favorite/${workout.publicId}`, { + method: "POST", + credentials: "include", }) - .then((json) => { - setIsFavorite(json.isFavorite); - }); + .then((res) => { + if (res.headers.has("Location")) + window.location.href = res.headers.get("Location") || ""; + else { + return res.json(); + } + }) + .then((json) => { + setUiElements((x) => ({ ...x, isFavorite: json.isFavorite })); + }); + } }; const equipment: string[] = []; @@ -225,25 +249,28 @@ function HomeIndex() { } const toggleAdvancedOptions = () => { - setIsAdvancedModalShown((old) => !old); + setUiElements((x) => ({ + ...x, + isAdvancedModalShown: !x.isAdvancedModalShown, + })); }; const handleFavoriteClick = () => { setFavorite(); - setLastClick("favorite"); + setUiElements((x) => ({ ...x, lastClick: "favorite" })); }; const handleCopyClick = () => { - navigator.clipboard - .writeText(`${window.location.origin}?id=${publicId}`) - .then( - () => { - setLastClick("copy"); - }, - () => { - console.error("Failed to copy"); - } - ); + if (workout && workout.publicId) { + navigator.clipboard + .writeText(`${window.location.origin}?id=${workout.publicId}`) + .then( + () => setUiElements((x) => ({ ...x, lastClick: "copy" })), + () => { + console.error("Failed to copy"); + } + ); + } }; return ( @@ -256,17 +283,24 @@ function HomeIndex() {
setIsTimingLocked(!isTimingLocked)} + className={`button ${ + uiElements.isTimingLocked ? "is-success" : "" + }`} + onClick={() => + setUiElements((x) => ({ + ...x, + isTimingLocked: !x.isTimingLocked, + })) + } > lock @@ -277,15 +311,25 @@ function HomeIndex() {
setIsFocusLocked(!isFocusLocked)} + className={`button ${ + uiElements.isFocusLocked ? "is-success" : "" + }`} + onClick={() => + setUiElements((x) => ({ + ...x, + isFocusLocked: !x.isFocusLocked, + })) + } > lock
- @@ -310,29 +354,35 @@ function HomeIndex() {
{workout && (

- Name: {workout.name} ·{" "} - Stations: {workout.stations} ·{" "} - Timing: {workout.timing} + Name: {workout.workout.name} ·{" "} + Stations: {workout.workout.stations} ·{" "} + Timing: {workout.workout.timing}

)} - {workout?.notes && ( -

Note: {workout.notes}

+ {workout?.workout?.notes && ( +

Note: {workout.workout.notes}

)}

- {publicId && ( + {workout?.publicId && ( <> + setUiElements((x) => ({ ...x, lastClick: "" })) } - onMouseOut={() => setLastClick("")} > star @@ -343,8 +393,12 @@ function HomeIndex() { className="button" onClick={handleCopyClick} title="Copy Link" - data-tooltip={lastClick == "copy" ? "Copied!" : "Copy URL"} - onMouseOut={() => setLastClick("")} + data-tooltip={ + uiElements.lastClick == "copy" ? "Copied!" : "Copy URL" + } + onMouseOut={() => + setUiElements((x) => ({ ...x, lastClick: "" })) + } > link @@ -377,30 +431,31 @@ function HomeIndex() { - {workout && - workout.exercises.map((row) => ( - - {row.station} - {row.exercise} - {row.focus} - - {row.equipment} - - - ))} + {workout?.workout.exercises.map((row) => ( + + {row.station} + {row.exercise} + {row.focus} + + {row.equipment} + + + ))}

@@ -429,8 +484,13 @@ function HomeIndex() {