diff --git a/PC2/Controllers/AboutController.cs b/PC2/Controllers/AboutController.cs index 28729c5b..fc974567 100644 --- a/PC2/Controllers/AboutController.cs +++ b/PC2/Controllers/AboutController.cs @@ -1,6 +1,5 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.EntityFrameworkCore; using PC2.Data; using PC2.Models; @@ -13,241 +12,10 @@ public class AboutController : Controller private readonly ApplicationDbContext _context; private readonly AzureBlobUploader _azureBlobUploader; - // Iwebhost environment is used to get the path to the wwwroot folder - public AboutController(ApplicationDbContext context, AzureBlobUploader azureBlobUploader) - { - _context = context; - _azureBlobUploader = azureBlobUploader; - } - - public async Task IndexStaff() - { - return View(await StaffDB.GetAllStaffForEditing(_context)); - } - - /// - /// Creates a staff member - /// - /// - [HttpGet] - public IActionResult CreateStaff() - { - return View(); - } - - [HttpPost] - public async Task CreateStaff(Staff staff) - { - if (ModelState.IsValid) - { - await StaffDB.AddStaff(_context, staff); - return RedirectToAction("IndexStaff"); - } - return View(staff); - } - - /// - /// Edits a staff member - /// - /// The id for the staff member - /// - [HttpGet] - public async Task EditStaff(int id) - { - return View(await StaffDB.GetStaffMember(_context, id)); - } - - [HttpPost] - public async Task EditStaff(Staff staff) - { - if (ModelState.IsValid) - { - await StaffDB.SaveChanges(_context, staff); - return RedirectToAction("IndexStaff"); - } - - return View(staff); - } - - /// - /// Deletes a staff member - /// - /// The id of the staff member - /// - [HttpGet] - public async Task DeleteStaff(int id) - { - return View(await StaffDB.GetStaffMember(_context, id)); - } - - [HttpPost] - [ActionName("DeleteStaff")] - public async Task ConfirmDeleteStaff(int id) - { - Staff? staff = await StaffDB.GetStaffMember(_context, id); - - if (staff == null) - { - // If staff member is not found - return NotFound(); // Return a NotFound result - } - - await StaffDB.Delete(_context, staff); - return RedirectToAction("IndexStaff"); - } - - public async Task IndexBoard() - { - return View(await BoardDB.GetAllBoardMembersForEditing(_context)); - } - - /// - /// Creates a board member - /// - /// - [HttpGet] - public IActionResult CreateBoard() - { - return View(); - } - - [HttpPost] - public async Task CreateBoard(Board board) - { - if (ModelState.IsValid) - { - await BoardDB.CreateBoardMember(_context, board); - return RedirectToAction("IndexBoard"); - } - - return View(board); - } - - /// - /// Edits a board member - /// - /// The id of the board member - /// - [HttpGet] - public async Task EditBoard(int id) - { - return View(await BoardDB.GetBoardMember(_context, id)); - } - - [HttpPost] - public async Task EditBoard(Board board) - { - if (ModelState.IsValid) - { - await BoardDB.EditBoardMember(_context, board); - return RedirectToAction("IndexBoard"); - } - - return View(board); - } - - /// - /// Deletes a board member - /// - /// The id of the board member - /// - [HttpGet] - public async Task DeleteBoard(int id) - { - return View(await BoardDB.GetBoardMember(_context, id)); - } - - [HttpPost] - [ActionName("DeleteBoard")] - public async Task ConfirmDeleteBoard(int id) - { - Board? board = await BoardDB.GetBoardMember(_context, id); - - if (board == null) - { - // If board member is not found - return NotFound(); // Return a NotFound result - } - - await BoardDB.Delete(_context, board); - return RedirectToAction("IndexBoard"); - } - - public async Task IndexSteeringCommittee() - { - return View(await SteeringCommitteeDB.GetAllSteeringCommittee(_context)); - } - - /// - /// Creates a steering committee member - /// - /// - [HttpGet] - public IActionResult CreateSteeringCommittee() - { - return View(); - } - - [HttpPost] - public async Task CreateSteeringCommittee(SteeringCommittee steeringCommittee) - { - if (ModelState.IsValid) - { - await SteeringCommitteeDB.Create(_context, steeringCommittee); - return RedirectToAction("IndexSteeringCommittee"); - } - - return View(steeringCommittee); - } - - /// - /// Gets a steering committee member by id - /// - /// The id of the steering committee member - /// - [HttpGet] - public async Task EditSteeringCommittee(int id) - { - return View(await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, id)); - } - - [HttpPost] - public async Task EditSteeringCommittee(SteeringCommittee steeringCommittee) - { - if (ModelState.IsValid) - { - await SteeringCommitteeDB.EditSteeringCommittee(_context, steeringCommittee); - return RedirectToAction("IndexSteeringCommittee"); - } - - return View(steeringCommittee); - } - - /// - /// Deletes a steering committee member by id - /// - /// The id of the member - /// - [HttpGet] - public async Task DeleteSteeringCommittee(int id) - { - return View(await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, id)); - } - - [HttpPost] - [ActionName("DeleteSteeringCommittee")] - public async Task ConfirmDeleteSteeringCommittee(int id) + public AboutController(ApplicationDbContext context, AzureBlobUploader azureBlobUploader) { - SteeringCommittee? steeringCommittee = await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, id); - - if (steeringCommittee == null) - { - // If the steering committee member is not found - return NotFound(); // Return a NotFound result - } - - await SteeringCommitteeDB.Delete(_context, steeringCommittee); - return RedirectToAction("IndexSteeringCommittee"); + _context = context; + _azureBlobUploader = azureBlobUploader; } public async Task HousingProgramData() @@ -260,7 +28,7 @@ public async Task HousingProgramData() public async Task HousingProgramData(HousingProgram model) { if (ModelState.IsValid) - { // if all the data is valid, update the database + { HousingProgram entry = new() { HouseHoldSize = model.HouseHoldSize, @@ -272,9 +40,8 @@ public async Task HousingProgramData(HousingProgram model) TempData["Message"] = $"Entry for Household size {entry.HouseHoldSize} updated Successfully"; return RedirectToAction("HousingProgramData"); } - // If model state is not valid, return the view with the model to show validation errors + List data = await _context.HousingProgram.OrderBy(hp => hp.HouseHoldSize).ToListAsync(); - // keep the data from user's input data[data.FindIndex(hp => hp.HouseHoldSize == model.HouseHoldSize)].MaximumIncome = model.MaximumIncome; TempData["Message"] = $"Maximum income must be a non - negative number at HouseHold size {model.HouseHoldSize}."; return View(data); @@ -294,7 +61,6 @@ public async Task UploadNewsletter(IFormFile userFile) { try { - // Upload the file to BLOB storage string filePath = await _azureBlobUploader.UploadFileAsync(userFile, userFile.FileName); TempData["Message"] = $"{userFile.FileName} uploaded successfully"; @@ -304,7 +70,6 @@ public async Task UploadNewsletter(IFormFile userFile) Location = filePath, }; - // add newsletterFile to the DB await NewsletterFileDB.AddAsync(_context, newsLetterFile); } catch (Exception ex) @@ -331,17 +96,14 @@ public async Task ConfirmDeleteNewsletter(int id) try { NewsletterFile? newsletter = await NewsletterFileDB.GetFileAsync(_context, id); - // delete actual file from wwwroot/PDF/focus-newsletters if (newsletter != null) { - // actual file name is never changed and object location is never changed when renaming - string? originalFile = newsletter.Location?.Split('/').LastOrDefault(); // Get the file name from the end of the URL + string? originalFile = newsletter.Location?.Split('/').LastOrDefault(); if (originalFile != null) { - bool isDeleted = await _azureBlobUploader.DeleteFileAsync(originalFile); // Delete the file from Azure Blob Storage + bool isDeleted = await _azureBlobUploader.DeleteFileAsync(originalFile); } - // remove from DB await NewsletterFileDB.DeleteAsync(_context, newsletter.NewsletterId); TempData["Message"] = $"{newsletter.Name} deleted successfully"; } @@ -357,9 +119,7 @@ public async Task ConfirmDeleteNewsletter(int id) [HttpGet] public async Task RenameNewsletter(int id) { - { - return View(await NewsletterFileDB.GetFileAsync(_context, id)); - } + return View(await NewsletterFileDB.GetFileAsync(_context, id)); } [HttpPost] @@ -367,16 +127,16 @@ public async Task RenameNewsletter(int id) public async Task ConfirmRenameNewsletter(int id) { NewsletterFile? newsletter = await NewsletterFileDB.GetFileAsync(_context, id); - + if (newsletter != null) { - string oldName = newsletter.Name; + string oldName = newsletter.Name; string newName = Request.Form["Name"]; - + await NewsletterFileDB.RenameFileAsync(_context, id, newName); TempData["Message"] = $"Newsletter {oldName} renamed to {newName}"; } - + return RedirectToAction("UploadNewsletter"); } } diff --git a/PC2/Controllers/PeopleController.cs b/PC2/Controllers/PeopleController.cs new file mode 100644 index 00000000..c717d936 --- /dev/null +++ b/PC2/Controllers/PeopleController.cs @@ -0,0 +1,279 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PC2.Data; +using PC2.Models; +using PC2.Models.ViewModels; +using PC2.Services; + +namespace PC2.Controllers; + +[Authorize(Roles = IdentityHelper.Admin)] +public class PeopleController : Controller +{ + private readonly ApplicationDbContext _context; + private readonly AzureBlobUploader _azureBlobUploader; + private readonly ImageService _imageService; + private readonly ILogger _logger; + + public PeopleController(ApplicationDbContext context, AzureBlobUploader azureBlobUploader, ImageService imageService, ILogger logger) + { + _context = context; + _azureBlobUploader = azureBlobUploader; + _imageService = imageService; + _logger = logger; + } + + public async Task Index(PersonType type) + { + ViewData["PersonType"] = type; + IEnumerable people = type switch + { + PersonType.Staff => (await StaffDB.GetAllStaffForEditing(_context)).Select(PersonViewModel.FromStaff), + PersonType.Board => (await BoardDB.GetAllBoardMembersForEditing(_context)).Select(PersonViewModel.FromBoard), + PersonType.SteeringCommittee => (await SteeringCommitteeDB.GetAllSteeringCommittee(_context)).Select(PersonViewModel.FromSteeringCommittee), + _ => [] + }; + return View(people); + } + + [HttpGet] + public IActionResult Create(PersonType type) + { + return View(new PersonViewModel { Type = type }); + } + + [HttpPost] + public async Task Create(PersonViewModel model) + { + ValidateTypeSpecificFields(model); + + if (ModelState.IsValid) + { + try + { + switch (model.Type) + { + case PersonType.Staff: + var staff = new Staff + { + Name = model.Name, + Title = model.Title, + Phone = model.Phone, + Extension = model.Extension, + Email = model.Email!, + PriorityOrder = model.PriorityOrder + }; + await HandlePhotoUpload(model.PhotoFile, staff); + await StaffDB.AddStaff(_context, staff); + break; + + case PersonType.Board: + var board = new Board + { + Name = model.Name, + Title = model.Title, + MembershipStart = model.MembershipStart!, + PriorityOrder = model.PriorityOrder + }; + await HandlePhotoUpload(model.PhotoFile, board); + await BoardDB.CreateBoardMember(_context, board); + break; + + case PersonType.SteeringCommittee: + var sc = new SteeringCommittee + { + Name = model.Name, + Title = model.Title, + PriorityOrder = model.PriorityOrder + }; + await HandlePhotoUpload(model.PhotoFile, sc); + await SteeringCommitteeDB.Create(_context, sc); + break; + } + return RedirectToAction(nameof(Index), new { type = model.Type }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating person record."); + TempData["Message"] = "There was a problem creating this record. Please try again later."; + } + } + return View(model); + } + + [HttpGet] + public async Task Edit(int id, PersonType type) + { + PersonViewModel? model = type switch + { + PersonType.Staff => await StaffDB.GetStaffMember(_context, id) is Staff s + ? PersonViewModel.FromStaff(s) : null, + PersonType.Board => await BoardDB.GetBoardMember(_context, id) is Board b + ? PersonViewModel.FromBoard(b) : null, + PersonType.SteeringCommittee => await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, id) is SteeringCommittee sc + ? PersonViewModel.FromSteeringCommittee(sc) : null, + _ => null + }; + + if (model == null) return NotFound(); + return View(model); + } + + [HttpPost] + public async Task Edit(PersonViewModel model) + { + ValidateTypeSpecificFields(model); + + if (ModelState.IsValid) + { + try + { + switch (model.Type) + { + case PersonType.Staff: + var staff = await StaffDB.GetStaffMember(_context, model.ID); + if (staff == null) return NotFound(); + staff.Name = model.Name; + staff.Title = model.Title; + staff.Phone = model.Phone; + staff.Extension = model.Extension; + staff.Email = model.Email!; + staff.PriorityOrder = model.PriorityOrder; + if (model.RemovePhoto) await RemovePersonPhoto(staff); + else if (model.PhotoFile != null) await HandlePhotoUpload(model.PhotoFile, staff, model.ID); + await StaffDB.SaveChanges(_context, staff); + break; + + case PersonType.Board: + var board = await BoardDB.GetBoardMember(_context, model.ID); + if (board == null) return NotFound(); + board.Name = model.Name; + board.Title = model.Title; + board.MembershipStart = model.MembershipStart!; + board.PriorityOrder = model.PriorityOrder; + if (model.RemovePhoto) await RemovePersonPhoto(board); + else if (model.PhotoFile != null) await HandlePhotoUpload(model.PhotoFile, board, model.ID); + await BoardDB.EditBoardMember(_context, board); + break; + + case PersonType.SteeringCommittee: + var sc = await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, model.ID); + if (sc == null) return NotFound(); + sc.Name = model.Name; + sc.Title = model.Title; + sc.PriorityOrder = model.PriorityOrder; + if (model.RemovePhoto) await RemovePersonPhoto(sc); + else if (model.PhotoFile != null) await HandlePhotoUpload(model.PhotoFile, sc, model.ID); + await SteeringCommitteeDB.EditSteeringCommittee(_context, sc); + break; + } + return RedirectToAction(nameof(Index), new { type = model.Type }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error editing person record."); + TempData["Message"] = "There was a problem saving this record. Please try again later."; + } + } + return View(model); + } + + [HttpGet] + public async Task Delete(int id, PersonType type) + { + PersonViewModel? model = type switch + { + PersonType.Staff => await StaffDB.GetStaffMember(_context, id) is Staff s + ? PersonViewModel.FromStaff(s) : null, + PersonType.Board => await BoardDB.GetBoardMember(_context, id) is Board b + ? PersonViewModel.FromBoard(b) : null, + PersonType.SteeringCommittee => await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, id) is SteeringCommittee sc + ? PersonViewModel.FromSteeringCommittee(sc) : null, + _ => null + }; + + if (model == null) return NotFound(); + return View(model); + } + + [HttpPost, ActionName("Delete")] + public async Task ConfirmDelete(int id, PersonType type) + { + try + { + switch (type) + { + case PersonType.Staff: + var staff = await StaffDB.GetStaffMember(_context, id); + if (staff == null) return NotFound(); + await RemovePersonPhoto(staff); + await StaffDB.Delete(_context, staff); + break; + + case PersonType.Board: + var board = await BoardDB.GetBoardMember(_context, id); + if (board == null) return NotFound(); + await RemovePersonPhoto(board); + await BoardDB.Delete(_context, board); + break; + + case PersonType.SteeringCommittee: + var sc = await SteeringCommitteeDB.GetSteeringCommitteeMember(_context, id); + if (sc == null) return NotFound(); + await RemovePersonPhoto(sc); + await SteeringCommitteeDB.Delete(_context, sc); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting person record."); + TempData["Message"] = "There was a problem deleting this record. Please try again later."; + } + + return RedirectToAction(nameof(Index), new { type }); + } + + private void ValidateTypeSpecificFields(PersonViewModel model) + { + if (model.Type == PersonType.Staff && string.IsNullOrWhiteSpace(model.Email)) + ModelState.AddModelError(nameof(PersonViewModel.Email), "Email is required for staff members."); + if (model.Type == PersonType.Board && string.IsNullOrWhiteSpace(model.MembershipStart)) + ModelState.AddModelError(nameof(PersonViewModel.MembershipStart), "Membership start year is required."); + } + + private async Task HandlePhotoUpload(IFormFile? photoFile, People person, int? personId = null) + { + if (photoFile == null || photoFile.Length == 0) return; + + if (!ImageService.IsValidImageFile(photoFile)) + throw new InvalidOperationException("Please upload a valid image file (JPEG, PNG, GIF, or BMP)."); + + if (personId.HasValue && !string.IsNullOrEmpty(person.ImageUrl)) + await RemovePersonPhoto(person); + + var safeFileName = ImageService.GetSafeImageFileName(photoFile.FileName, personId ?? 0); + using var resizedImageStream = await _imageService.ResizeImageAsync(photoFile.OpenReadStream(), 350, 350); + var resizedFormFile = new FormFileFromStream(resizedImageStream, safeFileName, photoFile.ContentType); + person.ImageUrl = await _azureBlobUploader.UploadFileAsync(resizedFormFile, safeFileName); + } + + private async Task RemovePersonPhoto(People person) + { + if (string.IsNullOrEmpty(person.ImageUrl)) return; + + try + { + var fileName = person.ImageUrl.Split('/').LastOrDefault(); + if (!string.IsNullOrEmpty(fileName)) + await _azureBlobUploader.DeleteFileAsync(fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting photo."); + throw; + } + + person.ImageUrl = null; + } +} diff --git a/PC2/Data/Migrations/20250807162448_AddImageUrlToStaff.Designer.cs b/PC2/Data/Migrations/20250807162448_AddImageUrlToStaff.Designer.cs new file mode 100644 index 00000000..c41b5fd0 --- /dev/null +++ b/PC2/Data/Migrations/20250807162448_AddImageUrlToStaff.Designer.cs @@ -0,0 +1,537 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PC2.Data; + +#nullable disable + +namespace PC2.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20250807162448_AddImageUrlToStaff")] + partial class AddImageUrlToStaff + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("AgencyAgencyCategory", b => + { + b.Property("AgenciesAgencyId") + .HasColumnType("int"); + + b.Property("AgencyCategoriesAgencyCategoryId") + .HasColumnType("int"); + + b.HasKey("AgenciesAgencyId", "AgencyCategoriesAgencyCategoryId"); + + b.HasIndex("AgencyCategoriesAgencyCategoryId"); + + b.ToTable("AgencyAgencyCategory"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PC2.Models.Agency", b => + { + b.Property("AgencyId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AgencyId")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AgencyName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AgencyName2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CrisisHelpHotline") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("Fax") + .HasColumnType("nvarchar(max)"); + + b.Property("MailingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TDD") + .HasColumnType("nvarchar(max)"); + + b.Property("TTY") + .HasColumnType("nvarchar(max)"); + + b.Property("TollFree") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("AgencyId"); + + b.ToTable("Agency"); + }); + + modelBuilder.Entity("PC2.Models.AgencyCategory", b => + { + b.Property("AgencyCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AgencyCategoryId")); + + b.Property("AgencyCategoryName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("AgencyCategoryId"); + + b.ToTable("AgencyCategory"); + }); + + modelBuilder.Entity("PC2.Models.CalendarEvent", b => + { + b.Property("CalendarEventID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CalendarEventID")); + + b.Property("CountyEvent") + .HasColumnType("bit"); + + b.Property("DateOfEvent") + .HasColumnType("date"); + + b.Property("EndingTime") + .HasColumnType("time"); + + b.Property("EventDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PC2Event") + .HasColumnType("bit"); + + b.Property("StartingTime") + .HasColumnType("time"); + + b.HasKey("CalendarEventID"); + + b.ToTable("CalendarEvents"); + }); + + modelBuilder.Entity("PC2.Models.HousingProgram", b => + { + b.Property("HouseHoldSize") + .HasColumnType("int"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("MaximumIncome") + .HasColumnType("float"); + + b.HasKey("HouseHoldSize"); + + b.ToTable("HousingProgram"); + }); + + modelBuilder.Entity("PC2.Models.NewsletterFile", b => + { + b.Property("NewsletterId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("NewsletterId")); + + b.Property("Location") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("NewsletterId"); + + b.ToTable("NewsletterFile"); + }); + + modelBuilder.Entity("PC2.Models.People", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("nvarchar(21)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PriorityOrder") + .HasColumnType("tinyint"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("ID"); + + b.ToTable("People"); + + b.HasDiscriminator().HasValue("People"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("PC2.Models.Board", b => + { + b.HasBaseType("PC2.Models.People"); + + b.Property("MembershipStart") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasDiscriminator().HasValue("Board"); + }); + + modelBuilder.Entity("PC2.Models.Staff", b => + { + b.HasBaseType("PC2.Models.People"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Extension") + .HasColumnType("int"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.HasDiscriminator().HasValue("Staff"); + }); + + modelBuilder.Entity("PC2.Models.SteeringCommittee", b => + { + b.HasBaseType("PC2.Models.People"); + + b.HasDiscriminator().HasValue("SteeringCommittee"); + }); + + modelBuilder.Entity("AgencyAgencyCategory", b => + { + b.HasOne("PC2.Models.Agency", null) + .WithMany() + .HasForeignKey("AgenciesAgencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PC2.Models.AgencyCategory", null) + .WithMany() + .HasForeignKey("AgencyCategoriesAgencyCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PC2/Data/Migrations/20250807162448_AddImageUrlToStaff.cs b/PC2/Data/Migrations/20250807162448_AddImageUrlToStaff.cs new file mode 100644 index 00000000..31fbf7fa --- /dev/null +++ b/PC2/Data/Migrations/20250807162448_AddImageUrlToStaff.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PC2.Data.Migrations +{ + /// + public partial class AddImageUrlToStaff : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ImageUrl", + table: "People", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ImageUrl", + table: "People"); + } + } +} diff --git a/PC2/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/PC2/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 24ed0f5e..9415dce2 100644 --- a/PC2/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/PC2/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -447,6 +447,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Extension") .HasColumnType("int"); + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + b.Property("Phone") .HasColumnType("nvarchar(max)"); diff --git a/PC2/Models/AzureBlobUploader.cs b/PC2/Models/AzureBlobUploader.cs index a0062f86..29b361b6 100644 --- a/PC2/Models/AzureBlobUploader.cs +++ b/PC2/Models/AzureBlobUploader.cs @@ -40,10 +40,9 @@ public async Task UploadFileAsync(IFormFile file, string blobName) var blobClient = containerClient.GetBlobClient(blobName); - using (var stream = file.OpenReadStream()) - { - await blobClient.UploadAsync(stream, overwrite: true); - } + // UploadFileAsync - caller owns the stream lifetime + var stream = file.OpenReadStream(); + await blobClient.UploadAsync(stream, overwrite: true); return blobClient.Uri.ToString(); } diff --git a/PC2/Models/FormFileFromStream.cs b/PC2/Models/FormFileFromStream.cs new file mode 100644 index 00000000..17c7d35e --- /dev/null +++ b/PC2/Models/FormFileFromStream.cs @@ -0,0 +1,42 @@ +namespace PC2.Models; + +/// +/// Implementation of IFormFile that wraps a Stream +/// Used for creating IFormFile from resized image streams +/// +public class FormFileFromStream : IFormFile +{ + private readonly Stream _stream; + private readonly string _name; + private readonly string _contentType; + + public FormFileFromStream(Stream stream, string name, string contentType) + { + _stream = stream; + _name = name; + _contentType = contentType; + } + + public string ContentType => _contentType; + public string ContentDisposition => $"form-data; name=\"{Name}\"; filename=\"{FileName}\""; + public IHeaderDictionary Headers => new HeaderDictionary(); + public long Length => _stream.Length; + public string Name => _name; + public string FileName => _name; + + public void CopyTo(Stream target) + { + _stream.CopyTo(target); + } + + public Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) + { + return _stream.CopyToAsync(target, cancellationToken); + } + + public Stream OpenReadStream() + { + _stream.Position = 0; + return _stream; + } +} \ No newline at end of file diff --git a/PC2/Models/People.cs b/PC2/Models/People.cs index 674e88bd..afd813d3 100644 --- a/PC2/Models/People.cs +++ b/PC2/Models/People.cs @@ -25,6 +25,12 @@ public class People /// is a higher priority /// public byte PriorityOrder { get; set; } + + /// + /// URL to the person's photo/image. + /// + [DataType(DataType.Url)] + public string? ImageUrl { get; set; } } /// @@ -91,4 +97,11 @@ public class Board : People /// public string MembershipStart { get; set; } } + + public enum PersonType + { + Staff, + Board, + SteeringCommittee + } } diff --git a/PC2/Models/ViewModels/PersonViewModel.cs b/PC2/Models/ViewModels/PersonViewModel.cs new file mode 100644 index 00000000..df7599f6 --- /dev/null +++ b/PC2/Models/ViewModels/PersonViewModel.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; + +namespace PC2.Models.ViewModels; + +public class PersonViewModel +{ + public int ID { get; set; } + + [Required] + public PersonType Type { get; set; } + + // ---- Common (People base) ---- + + [Required] + public string Name { get; set; } = string.Empty; + + public string? Title { get; set; } + + [Required] + [Display(Name = "Sort Priority")] + public byte PriorityOrder { get; set; } = 10; + + // ---- Photo ---- + + public string? CurrentImageUrl { get; set; } + + [Display(Name = "Photo")] + public IFormFile? PhotoFile { get; set; } + + [Display(Name = "Remove current photo")] + public bool RemovePhoto { get; set; } + + // ---- Staff-specific ---- + + public string? Phone { get; set; } + + public int? Extension { get; set; } + + [DataType(DataType.EmailAddress)] + [EmailAddress] + public string? Email { get; set; } + + // ---- Board-specific ---- + + [Display(Name = "Membership Start Year")] + public string? MembershipStart { get; set; } + + // ---- Factory methods ---- + + public static PersonViewModel FromStaff(Staff s) => new() + { + ID = s.ID, + Type = PersonType.Staff, + Name = s.Name, + Title = s.Title, + PriorityOrder = s.PriorityOrder, + CurrentImageUrl = s.ImageUrl, + Phone = s.Phone, + Extension = s.Extension, + Email = s.Email + }; + + public static PersonViewModel FromBoard(Board b) => new() + { + ID = b.ID, + Type = PersonType.Board, + Name = b.Name, + Title = b.Title, + PriorityOrder = b.PriorityOrder, + CurrentImageUrl = b.ImageUrl, + MembershipStart = b.MembershipStart + }; + + public static PersonViewModel FromSteeringCommittee(SteeringCommittee sc) => new() + { + ID = sc.ID, + Type = PersonType.SteeringCommittee, + Name = sc.Name, + Title = sc.Title, + PriorityOrder = sc.PriorityOrder, + CurrentImageUrl = sc.ImageUrl + }; +} diff --git a/PC2/PC2.csproj b/PC2/PC2.csproj index b013cf31..7f878e28 100644 --- a/PC2/PC2.csproj +++ b/PC2/PC2.csproj @@ -36,6 +36,7 @@ + diff --git a/PC2/Program.cs b/PC2/Program.cs index 73fe07d3..751fcdf9 100644 --- a/PC2/Program.cs +++ b/PC2/Program.cs @@ -18,6 +18,9 @@ // Register AzureBlobUploader for DI builder.Services.AddSingleton(); +// Register ImageService for DI +builder.Services.AddScoped(); + // Register AnalyticsService for DI builder.Services.AddScoped(); diff --git a/PC2/Services/ImageService.cs b/PC2/Services/ImageService.cs new file mode 100644 index 00000000..8c99479d --- /dev/null +++ b/PC2/Services/ImageService.cs @@ -0,0 +1,128 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Bmp; + +namespace PC2.Services +{ + /// + /// Service for image processing operations like resizing using ImageSharp + /// + public class ImageService + { + private readonly ILogger _logger; + + public ImageService(ILogger logger) + { + _logger = logger; + } + + /// + /// Resizes an image to the specified maximum dimensions while maintaining aspect ratio + /// + /// The input image stream + /// Maximum width + /// Maximum height + /// Resized image as a memory stream + public async Task ResizeImageAsync(Stream imageStream, int maxWidth = 800, int maxHeight = 600) + { + try + { + using var image = await Image.LoadAsync(imageStream); + + // Calculate new dimensions while maintaining aspect ratio + (int newWidth, int newHeight) = CalculateResizeDimensions(image.Width, image.Height, maxWidth, maxHeight); + + // If image is already smaller than max dimensions, return original + if (newWidth == image.Width && newHeight == image.Height) + { + var originalStream = new MemoryStream(); + imageStream.Position = 0; + await imageStream.CopyToAsync(originalStream); + originalStream.Position = 0; + return originalStream; + } + + // Resize the image + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(newWidth, newHeight), + Mode = ResizeMode.Max, // Ensure it fits within max dimensions + Sampler = KnownResamplers.Lanczos3 // High quality resampling (more CPU intensive) + })); + + // Save to memory stream + var outputStream = new MemoryStream(); + + // Use JPEG format with good quality for most cases + await image.SaveAsJpegAsync(outputStream, new JpegEncoder + { + Quality = 85 + }); + + outputStream.Position = 0; + return outputStream; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resizing image"); + throw new InvalidOperationException("Failed to resize image", ex); + } + } + + /// + /// Calculates new dimensions for resizing while maintaining aspect ratio. Minimizes the size to fit within maxWidth and maxHeight. + /// + private static (int width, int height) CalculateResizeDimensions(int originalWidth, int originalHeight, int maxWidth, int maxHeight) + { + // If image is already within bounds, return original size + if (originalWidth <= maxWidth && originalHeight <= maxHeight) + return (originalWidth, originalHeight); + + // Calculate scaling ratios + double widthRatio = (double)maxWidth / originalWidth; + double heightRatio = (double)maxHeight / originalHeight; + + // Use the smaller ratio to ensure image fits within both dimensions + double ratio = Math.Min(widthRatio, heightRatio); + + int newWidth = (int)(originalWidth * ratio); + int newHeight = (int)(originalHeight * ratio); + + return (newWidth, newHeight); + } + + /// + /// Validates if the uploaded file is a valid image + /// + public static bool IsValidImageFile(IFormFile file) + { + if (file == null || file.Length == 0) + return false; + + var allowedMimeTypes = new[] + { + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/bmp", + "image/webp" + }; + + return allowedMimeTypes.Contains(file.ContentType?.ToLower()); + } + + /// + /// Gets a safe filename for uploaded images + /// + public static string GetSafeImageFileName(string originalFileName, int personId) + { + var extension = Path.GetExtension(originalFileName); + var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + return $"person_{personId}_{timestamp}{extension}"; + } + } +} \ No newline at end of file diff --git a/PC2/Views/About/CreateBoard.cshtml b/PC2/Views/About/CreateBoard.cshtml deleted file mode 100644 index 49fb74f7..00000000 --- a/PC2/Views/About/CreateBoard.cshtml +++ /dev/null @@ -1,48 +0,0 @@ -@model PC2.Models.Board - -@{ - ViewData["Title"] = "Create Board Member"; -} - -

@ViewData["Title"]

- -
-
-
-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- -
-
-
-
- - - diff --git a/PC2/Views/About/CreateStaff.cshtml b/PC2/Views/About/CreateStaff.cshtml deleted file mode 100644 index 98ffb295..00000000 --- a/PC2/Views/About/CreateStaff.cshtml +++ /dev/null @@ -1,56 +0,0 @@ -@model PC2.Models.Staff - -@{ - ViewData["Title"] = "Create Staff Member"; -} - -

@ViewData["Title"]

-
-
-
-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- -
-
-
-
- - - diff --git a/PC2/Views/About/CreateSteeringCommittee.cshtml b/PC2/Views/About/CreateSteeringCommittee.cshtml deleted file mode 100644 index c07e581a..00000000 --- a/PC2/Views/About/CreateSteeringCommittee.cshtml +++ /dev/null @@ -1,34 +0,0 @@ -@model PC2.Models.SteeringCommittee - -@{ - ViewData["Title"] = "Create Steering Committee Member"; -} - -

@ViewData["Title"]

- -
-
-
-
-
-
- - - -
-
- - - -
-
- -
-
-
-
- - - diff --git a/PC2/Views/About/DeleteBoard.cshtml b/PC2/Views/About/DeleteBoard.cshtml deleted file mode 100644 index 1f20c6e9..00000000 --- a/PC2/Views/About/DeleteBoard.cshtml +++ /dev/null @@ -1,38 +0,0 @@ -@model PC2.Models.Board - -@{ - ViewData["Title"] = "Delete Board Member"; -} - -

Delete @Model.Name

- -

Are you sure you want to delete @Model.Name?

-
-
-
-
- @Html.DisplayNameFor(model => model.Name) -
-
- @Html.DisplayFor(model => model.Name) -
-
- @Html.DisplayNameFor(model => model.Title) -
-
- @Html.DisplayFor(model => model.Title) -
-
- @Html.DisplayNameFor(model => model.MembershipStart) -
-
- @Html.DisplayFor(model => model.MembershipStart) -
-
- -
- - | - Back to List -
-
diff --git a/PC2/Views/About/DeleteStaff.cshtml b/PC2/Views/About/DeleteStaff.cshtml deleted file mode 100644 index f6fd3fa5..00000000 --- a/PC2/Views/About/DeleteStaff.cshtml +++ /dev/null @@ -1,50 +0,0 @@ -@model PC2.Models.Staff - -@{ - ViewData["Title"] = "Delete Staff Member"; -} - -

Delete @Model.Name

- -

Are you sure you want to delete @Model.Name?

-
-
-
-
- @Html.DisplayNameFor(model => model.Name) -
-
- @Html.DisplayFor(model => model.Name) -
-
- @Html.DisplayNameFor(model => model.Title) -
-
- @Html.DisplayFor(model => model.Title) -
-
- @Html.DisplayNameFor(model => model.Phone) -
-
- @Html.DisplayFor(model => model.Phone) -
-
- @Html.DisplayNameFor(model => model.Extension) -
-
- @Html.DisplayFor(model => model.Extension) -
-
- @Html.DisplayNameFor(model => model.Email) -
-
- @Html.DisplayFor(model => model.Email) -
-
- -
- - | - Back to List -
-
diff --git a/PC2/Views/About/DeleteSteeringCommittee.cshtml b/PC2/Views/About/DeleteSteeringCommittee.cshtml deleted file mode 100644 index 1aebc920..00000000 --- a/PC2/Views/About/DeleteSteeringCommittee.cshtml +++ /dev/null @@ -1,32 +0,0 @@ -@model PC2.Models.SteeringCommittee - -@{ - ViewData["Title"] = "Delete Steering Committee Member"; -} - -

Delete @Model.Name

- -

Are you sure you want to delete @Model.Name?

-
-
-
-
- @Html.DisplayNameFor(model => model.Name) -
-
- @Html.DisplayFor(model => model.Name) -
-
- @Html.DisplayNameFor(model => model.Title) -
-
- @Html.DisplayFor(model => model.Title) -
-
- -
- - | - Back to List -
-
diff --git a/PC2/Views/About/EditBoard.cshtml b/PC2/Views/About/EditBoard.cshtml deleted file mode 100644 index c33a5408..00000000 --- a/PC2/Views/About/EditBoard.cshtml +++ /dev/null @@ -1,40 +0,0 @@ -@model PC2.Models.Board - -@{ - ViewData["Title"] = "Edit Board Member"; -} - -

Edit @Model.Name

- -
-
-
-
-
- -
- - - -
-
- - - -
-
- - - -
-
- -
-
-
-
- - - diff --git a/PC2/Views/About/EditStaff.cshtml b/PC2/Views/About/EditStaff.cshtml deleted file mode 100644 index 4bbfc581..00000000 --- a/PC2/Views/About/EditStaff.cshtml +++ /dev/null @@ -1,58 +0,0 @@ -@model PC2.Models.Staff - -@{ - ViewData["Title"] = "Edit Staff Member"; -} - -

Edit @Model.Name

- -
-
-
-
-
- -
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- -
-
-
-
- - - diff --git a/PC2/Views/About/EditSteeringCommittee.cshtml b/PC2/Views/About/EditSteeringCommittee.cshtml deleted file mode 100644 index 432cac51..00000000 --- a/PC2/Views/About/EditSteeringCommittee.cshtml +++ /dev/null @@ -1,35 +0,0 @@ -@model PC2.Models.SteeringCommittee - -@{ - ViewData["Title"] = "Edit Steering Committee Member"; -} - -

Edit @Model.Name

- -
-
-
-
-
- -
- - - -
-
- - - -
-
- -
-
-
-
- - - diff --git a/PC2/Views/About/IndexBoard.cshtml b/PC2/Views/About/IndexBoard.cshtml deleted file mode 100644 index 364e5b12..00000000 --- a/PC2/Views/About/IndexBoard.cshtml +++ /dev/null @@ -1,46 +0,0 @@ -@model IEnumerable - -@{ - ViewData["Title"] = "Board Members"; -} - -

@ViewData["Title"]

- -

- Create New Board Member -

- - - - - - - - - - -@foreach (var item in Model) { - - - - - - -} - -
- @Html.DisplayNameFor(model => model.Name) - - @Html.DisplayNameFor(model => model.Title) - - Start of Membership -
- @Html.DisplayFor(modelItem => item.Name) - - @Html.DisplayFor(modelItem => item.Title) - - Member since @Html.DisplayFor(modelItem => item.MembershipStart) - - Edit | - Delete -
diff --git a/PC2/Views/About/IndexStaff.cshtml b/PC2/Views/About/IndexStaff.cshtml deleted file mode 100644 index 75d651de..00000000 --- a/PC2/Views/About/IndexStaff.cshtml +++ /dev/null @@ -1,58 +0,0 @@ -@model IEnumerable - -@{ - ViewData["Title"] = "Staff Members"; -} - -

@ViewData["Title"]

- -

- Create New Staff Member -

- - - - - - - - - - - - -@foreach (var item in Model) { - - - - - - - - -} - -
- @Html.DisplayNameFor(model => model.Name) - - @Html.DisplayNameFor(model => model.Title) - - @Html.DisplayNameFor(model => model.Phone) - - @Html.DisplayNameFor(model => model.Extension) - - @Html.DisplayNameFor(model => model.Email) -
- @Html.DisplayFor(modelItem => item.Name) - - @Html.DisplayFor(modelItem => item.Title) - - @Html.DisplayFor(modelItem => item.Phone) - - @Html.DisplayFor(modelItem => item.Extension) - - @Html.DisplayFor(modelItem => item.Email) - - Edit | - Delete -
diff --git a/PC2/Views/About/IndexSteeringCommittee.cshtml b/PC2/Views/About/IndexSteeringCommittee.cshtml deleted file mode 100644 index aa61436d..00000000 --- a/PC2/Views/About/IndexSteeringCommittee.cshtml +++ /dev/null @@ -1,40 +0,0 @@ -@model IEnumerable - -@{ - ViewData["Title"] = "Steering Committee Members"; -} - -

@ViewData["Title"]

- -

- Create New -

- - - - - - - - - -@foreach (var item in Model) { - - - - - -} - -
- @Html.DisplayNameFor(model => model.Name) - - @Html.DisplayNameFor(model => model.Title) -
- @Html.DisplayFor(modelItem => item.Name) - - @Html.DisplayFor(modelItem => item.Title) - - Edit | - Delete -
diff --git a/PC2/Views/Home/About.cshtml b/PC2/Views/Home/About.cshtml index 6ee58113..0e10291a 100644 --- a/PC2/Views/Home/About.cshtml +++ b/PC2/Views/Home/About.cshtml @@ -2,121 +2,244 @@ @{ ViewData["Title"] = "About Us"; } +@section Head{ + + .people-list > div { + margin-bottom: 1em; + text-align: center; + } + .people-list > h2 { + font-size: 1.3em; + text-align: center; + margin: 2em 0 1.25em 0; + } -
-
- BethAnn Garteiz, smiling in a professional headshot - Cary Vazquez, wearing a suit and tie in a formal portrait - Christopher Cleveland, casual portrait with a relaxed expression - Dale Golder, presenting at a conference - PC2 employee - Jauna Balderston-Todd, standing outdoors with a friendly expression - Kristin Loos, seated at a desk with a welcoming smile - PC2 employee - Michele Lehosky - Nathan Becker -
-
+ /* Minimal custom CSS for circular images */ + .person-avatar { + width: 150px; + height: 150px; + object-fit: cover; + } + +}
-

@ViewData["Title"]

+

@ViewData["Title"]

Our Staff

-
- - - @for (int i = 0; i < Model.Staff.Count; i++) - { - - - - } - -
-

@Model.Staff[i].Name

-

@Model.Staff[i].Title

- @if(Model.Staff[i].PhoneDisplay != null) + @* Display staff with images using Bootstrap Cards *@ + @if (Model.Staff.Any(s => !string.IsNullOrEmpty(s.ImageUrl))) + { +
+ @foreach (var staff in Model.Staff.Where(s => !string.IsNullOrEmpty(s.ImageUrl))) + { +
+
+
+ @(string.IsNullOrEmpty(staff.Title) ? staff.Name : staff.Name + +
@staff.Name
+ @if (!string.IsNullOrEmpty(staff.Title)) { -

- @Model.Staff[i].PhoneDisplay -

+

@staff.Title

} - @Model.Staff[i].Email -
-
+
+ @if(staff.PhoneDisplay != null) + { +

+ @staff.PhoneDisplay +

+ } +

+ @staff.Email +

+
+
+ + + } + + } + + @* Fall back to table for staff without images *@ + @if (Model.Staff.Any(s => string.IsNullOrEmpty(s.ImageUrl))) + { +
+
+
+
+
Additional Staff Members
+
+
+
+ + + @foreach (var staff in Model.Staff.Where(s => string.IsNullOrEmpty(s.ImageUrl))) + { + + + + } + +
+

@staff.Name

+ @if (!string.IsNullOrEmpty(staff.Title)) + { +

@staff.Title

+ } + @if(staff.PhoneDisplay != null) + { +

+ @staff.PhoneDisplay +

+ } +

+ @staff.Email +

+
+
+
+
+
+
+ } +

Our Board

-
- - - @for (int i = 0; i < Model.Board.Count; i++) - { - - - - } - -
-

@Model.Board[i].Name

- @if (Model.Board[i].Title != null) + + @if (Model.Board.Any(b => !string.IsNullOrEmpty(b.ImageUrl))) + { +
+ @foreach (var board in Model.Board.Where(b => !string.IsNullOrEmpty(b.ImageUrl))) + { +
+
+
+ @board.Name +
@board.Name
+ @if (!string.IsNullOrEmpty(board.Title)) { -

@Model.Board[i].Title

+

@board.Title

} -

Member since @Model.Board[i].MembershipStart

-
-
+

Member since @board.MembershipStart

+ + + + } + + } + + @if (Model.Board.Any(b => string.IsNullOrEmpty(b.ImageUrl))) + { +
+
+
+
+
@(Model.Board.Any(b => !string.IsNullOrEmpty(b.ImageUrl)) ? "Additional Board Members" : "Board Members")
+
+
+
+ + + @foreach (var board in Model.Board.Where(b => string.IsNullOrEmpty(b.ImageUrl))) + { + + + + } + +
+

@board.Name

+ @if (!string.IsNullOrEmpty(board.Title)) + { +

@board.Title

+ } +

Member since @board.MembershipStart

+
+
+
+
+
+
+ } + @if (Model.SteeringCommittee.Count > 0) {

Our Steering Committee

-
- - - @for (int i = 0; i < Model.SteeringCommittee.Count; i++) - { - - - - } - -
-

@Model.SteeringCommittee[i].Name

-

@Model.SteeringCommittee[i].Title

-
-
- } \ No newline at end of file + + @if (Model.SteeringCommittee.Any(sc => !string.IsNullOrEmpty(sc.ImageUrl))) + { +
+ @foreach (var member in Model.SteeringCommittee.Where(sc => !string.IsNullOrEmpty(sc.ImageUrl))) + { +
+
+
+ @member.Name +
@member.Name
+ @if (!string.IsNullOrEmpty(member.Title)) + { +

@member.Title

+ } +
+
+
+ } +
+ } + + @if (Model.SteeringCommittee.Any(sc => string.IsNullOrEmpty(sc.ImageUrl))) + { +
+
+
+
+
@(Model.SteeringCommittee.Any(sc => !string.IsNullOrEmpty(sc.ImageUrl)) ? "Additional Steering Committee Members" : "Steering Committee Members")
+
+
+
+ + + @foreach (var member in Model.SteeringCommittee.Where(sc => string.IsNullOrEmpty(sc.ImageUrl))) + { + + + + } + +
+

@member.Name

+ @if (!string.IsNullOrEmpty(member.Title)) + { +

@member.Title

+ } +
+
+
+
+
+
+ } + } + \ No newline at end of file diff --git a/PC2/Views/People/Create.cshtml b/PC2/Views/People/Create.cshtml new file mode 100644 index 00000000..2c876085 --- /dev/null +++ b/PC2/Views/People/Create.cshtml @@ -0,0 +1,140 @@ +@model PC2.Models.ViewModels.PersonViewModel +@using PC2.Models + +@{ + ViewData["Title"] = Model.Type switch + { + PersonType.Staff => "Create Staff Member", + PersonType.Board => "Create Board Member", + PersonType.SteeringCommittee => "Create Steering Committee Member", + _ => "Create Member" + }; +} + +

@ViewData["Title"]

+
+
+
+
+
+ + +
+ + + +
+ +
+ + + +
+ + @if (Model.Type == PersonType.Staff) + { +
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+ + + +
+ } + + @if (Model.Type == PersonType.Board) + { +
+ + + +
+ } + +
+
+
Photo
+
+
+
+ + + + + Upload a photo file (JPEG, PNG, GIF, or BMP). Large images will be automatically resized. + + +
+
+
+ + @if (Model.Type != PersonType.SteeringCommittee) + { +
+ + @if (Model.Type == PersonType.Staff) + { + + } + else + { + + } + +
+ } + else + { + + } + +
+ + Cancel +
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} + +} diff --git a/PC2/Views/People/Delete.cshtml b/PC2/Views/People/Delete.cshtml new file mode 100644 index 00000000..c93f594f --- /dev/null +++ b/PC2/Views/People/Delete.cshtml @@ -0,0 +1,55 @@ +@model PC2.Models.ViewModels.PersonViewModel +@using PC2.Models + +@{ + ViewData["Title"] = Model.Type switch + { + PersonType.Staff => "Delete Staff Member", + PersonType.Board => "Delete Board Member", + PersonType.SteeringCommittee => "Delete Steering Committee Member", + _ => "Delete Member" + }; +} + +

Delete @Model.Name

+ +

Are you sure you want to delete @Model.Name?

+
+
+ + @if (!string.IsNullOrEmpty(Model.CurrentImageUrl)) + { +
+ @Model.Name +
+ } + +
+
Name
+
@Model.Name
+
Title
+
@Model.Title
+ + @if (Model.Type == PersonType.Staff) + { +
Phone
+
@Model.Phone
+
Email
+
@Model.Email
+ } + + @if (Model.Type == PersonType.Board) + { +
Member Since
+
@Model.MembershipStart
+ } +
+ +
+ + + | + Back to List +
+
diff --git a/PC2/Views/People/Edit.cshtml b/PC2/Views/People/Edit.cshtml new file mode 100644 index 00000000..78ca8df2 --- /dev/null +++ b/PC2/Views/People/Edit.cshtml @@ -0,0 +1,174 @@ +@model PC2.Models.ViewModels.PersonViewModel +@using PC2.Models + +@{ + ViewData["Title"] = Model.Type switch + { + PersonType.Staff => "Edit Staff Member", + PersonType.Board => "Edit Board Member", + PersonType.SteeringCommittee => "Edit Steering Committee Member", + _ => "Edit Member" + }; +} + +

Edit @Model.Name

+
+
+
+
+
+ + + + +
+ + + +
+ +
+ + + +
+ + @if (Model.Type == PersonType.Staff) + { +
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+ + + +
+ } + + @if (Model.Type == PersonType.Board) + { +
+ + + +
+ } + +
+
+
Photo
+
+
+ @if (!string.IsNullOrEmpty(Model.CurrentImageUrl)) + { +
+ +
+ @Model.Name +
+
+
+ + +
+ } +
+ + + + + Upload a new photo (JPEG, PNG, GIF, or BMP). Large images will be automatically resized. + + +
+
+
+ + @if (Model.Type != PersonType.SteeringCommittee) + { +
+ + @if (Model.Type == PersonType.Staff) + { + + } + else + { + + } + +
+ } + else + { + + } + +
+ + Cancel +
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} + +} diff --git a/PC2/Views/People/Index.cshtml b/PC2/Views/People/Index.cshtml new file mode 100644 index 00000000..14fbbbdb --- /dev/null +++ b/PC2/Views/People/Index.cshtml @@ -0,0 +1,76 @@ +@model IEnumerable +@using PC2.Models + +@{ + var type = (PersonType)ViewData["PersonType"]!; + ViewData["Title"] = type switch + { + PersonType.Staff => "Staff Members", + PersonType.Board => "Board Members", + PersonType.SteeringCommittee => "Steering Committee Members", + _ => "Members" + }; +} + +

@ViewData["Title"]

+ +

+ Create New +

+ + + + + + + + @if (type == PersonType.Staff) + { + + + + } + @if (type == PersonType.Board) + { + + } + + + + + @foreach (var item in Model) + { + + + + + @if (type == PersonType.Staff) + { + + + + } + @if (type == PersonType.Board) + { + + } + + + } + +
NameTitlePhoneExtensionEmailMembership Start
+ @if (!string.IsNullOrEmpty(item.CurrentImageUrl)) + { + @($ + } + else + { + + + + } + @item.Name@item.Title@item.Phone@item.Extension@item.EmailMember since @item.MembershipStart + Edit | + Delete +
diff --git a/PC2/Views/Shared/_Layout.cshtml b/PC2/Views/Shared/_Layout.cshtml index 5a24769c..dcfa0d3c 100644 --- a/PC2/Views/Shared/_Layout.cshtml +++ b/PC2/Views/Shared/_Layout.cshtml @@ -102,12 +102,12 @@ Members diff --git a/PC2/wwwroot/css/site.css b/PC2/wwwroot/css/site.css index b98e129f..852fd966 100644 --- a/PC2/wwwroot/css/site.css +++ b/PC2/wwwroot/css/site.css @@ -118,7 +118,7 @@ main { #heading { width: 75%; - margin: 8% auto auto auto; + margin: 1em auto auto auto; padding-right: 10px; padding-left: 10px; } @@ -386,10 +386,6 @@ nav a { font-size: 16px; } - #pc2Logo { - padding-bottom: 1em; - } - #menu-bar * { width: 0; } diff --git a/PC2/wwwroot/images/about/au1.jpg b/PC2/wwwroot/images/about/au1.jpg deleted file mode 100644 index 6dafbf7e..00000000 Binary files a/PC2/wwwroot/images/about/au1.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au10.png b/PC2/wwwroot/images/about/au10.png deleted file mode 100644 index 337cb9f9..00000000 Binary files a/PC2/wwwroot/images/about/au10.png and /dev/null differ diff --git a/PC2/wwwroot/images/about/au2.jpg b/PC2/wwwroot/images/about/au2.jpg deleted file mode 100644 index 2fd51411..00000000 Binary files a/PC2/wwwroot/images/about/au2.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au3.jpg b/PC2/wwwroot/images/about/au3.jpg deleted file mode 100644 index 806c98db..00000000 Binary files a/PC2/wwwroot/images/about/au3.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au4.jpg b/PC2/wwwroot/images/about/au4.jpg deleted file mode 100644 index 1cb6f989..00000000 Binary files a/PC2/wwwroot/images/about/au4.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au5.png b/PC2/wwwroot/images/about/au5.png deleted file mode 100644 index 306578ea..00000000 Binary files a/PC2/wwwroot/images/about/au5.png and /dev/null differ diff --git a/PC2/wwwroot/images/about/au6.jpg b/PC2/wwwroot/images/about/au6.jpg deleted file mode 100644 index a86c5d75..00000000 Binary files a/PC2/wwwroot/images/about/au6.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au7.jpg b/PC2/wwwroot/images/about/au7.jpg deleted file mode 100644 index 47cd99b5..00000000 Binary files a/PC2/wwwroot/images/about/au7.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au8.jpg b/PC2/wwwroot/images/about/au8.jpg deleted file mode 100644 index 21f1e6c7..00000000 Binary files a/PC2/wwwroot/images/about/au8.jpg and /dev/null differ diff --git a/PC2/wwwroot/images/about/au9.png b/PC2/wwwroot/images/about/au9.png deleted file mode 100644 index 457a6754..00000000 Binary files a/PC2/wwwroot/images/about/au9.png and /dev/null differ