@using Microsoft.AspNetCore.Components.Web @using Microsoft.EntityFrameworkCore @using Microsoft.Extensions.Options @using OpenArchival.Blazor.AdminPages @using OpenArchival.DataAccess @using OpenArchival.Blazor.CustomComponents @using MudExRichTextEditor @using MudBlazor @using System.Text @using Microsoft.AspNetCore.Components.Forms; @using OpenArchival.Blazor.Config @using OpenArchival.Blazor.AdminPages.Shared; @page "/admin/blogedit" @page "/admin/blogedit/{Id:int}" @layout AdminControlPanelLayout; Create a Blog Post Upload Main Photo @if (context != null) { @context.Name } else if (BlogPostModel.MainPhoto != null) { @BlogPostModel.MainPhoto.OriginalName } else { No Main Photo Selected } @((MarkupString)BlogPostModel.Content) Add Tags Press enter to add. Link Artifact Groupings Select Artifact Groupings to Associate with this post. Publish @inject IOptions FileOptions; @inject IDbContextFactory ContextFactory; @inject ISnackbar Snackbar; @inject NavigationManager NavigationManager; @*OnKeyDown="@(ev => HandleChipContainerEnter(ev, _tagsChipContainer, _tagsInputValue, () => _tagsInputValue = string.Empty))"*@ @code { [Parameter] public int Id { get; set; } public List StringTags { get; set; } = []; public BlogPost BlogPostModel { get; set; } = new(); private MudExRichTextEdit _contentEditor = default!; private string _tagsInputValue = ""; private ChipContainer _tagsChipContainer = default!; private ArchiveGroupingsTable _artifactGroupingTable = default!; private IBrowserFile? _mainPhotoFile; protected override void OnParametersSet() { if (Id != 0) { using var context = ContextFactory.CreateDbContext(); BlogPost? existingPost = context.BlogPosts .Include(p => p.Tags) .Include(p=>p.ArtifactGroupings) .ThenInclude(g=>g.Category) .Include(p=>p.MainPhoto) .Where(p => p.Id == Id) .FirstOrDefault(); if (existingPost is null) { NavigationManager.NavigateTo($"blog-not-found/{System.Web.HttpUtility.UrlEncode($"No blog with id {Id} exists in the database!")}"); return; } BlogPostModel = existingPost; } base.OnParametersSet(); } protected override async Task OnAfterRenderAsync(bool firstRender) { // We only want to run this logic ONCE, on the very first render. if (firstRender) { // Check if we are in "edit" mode (Id != 0) and the loaded post // actually has any groupings to select. if (Id != 0 && BlogPostModel.ArtifactGroupings.Any()) { // Now it is safe to call the method because _artifactGroupingTable // has been rendered and is no longer null. await _artifactGroupingTable.SetSelectedItemsAsync(BlogPostModel.ArtifactGroupings); // We call StateHasChanged() to ensure the UI updates // with the selection state we just pushed to the child. StateHasChanged(); } } await base.OnAfterRenderAsync(firstRender); } private async Task DeDuplicatePostTags(BlogPost post, ApplicationDbContext context) { List finalTags = []; foreach (BlogPostTag tag in post.Tags) { var existing = await context.BlogPostTags.Where(t => t.Name == tag.Name).FirstOrDefaultAsync(); if (existing is not null) { finalTags.Add(existing); } else { context.BlogPostTags.Add(tag); finalTags.Add(tag); } } post.Tags = finalTags; } private void BuildBlogSearchIndex(BlogPost post) { var tagsSb = new StringBuilder(); var allSb = new StringBuilder(); allSb.Append($"{post.Title} "); allSb.Append($"{post.Content} "); foreach (BlogPostTag tag in post.Tags) { tagsSb.Append($"{tag.Name} "); allSb.Append($"{tag.Name} "); } post.AllSearchString = allSb.ToString(); post.TagsSearchString = tagsSb.ToString(); } private async Task OnPublishBlogPost(Microsoft.AspNetCore.Components.Web.MouseEventArgs args) { // ... (content and title checks) ... await using var context = await ContextFactory.CreateDbContextAsync(); bool isCreatingNewPost = BlogPostModel.Id == 0; FilePathListing? newPhoto = null; // Will hold the new photo entity, if one is uploaded FilePathListing? oldPhotoToDelete = null; // Will hold the old photo entity for deletion // --- 1. Handle New Photo Upload --- if (_mainPhotoFile is not null) { // A new file was uploaded. try { var diskFileName = $"{Guid.NewGuid()}{Path.GetExtension(_mainPhotoFile.Name)}"; var destinationPath = Path.Combine(FileOptions.Value.UploadFolderPath, diskFileName); await using var browserUploadStream = _mainPhotoFile.OpenReadStream(maxAllowedSize: FileOptions.Value.MaxUploadSizeBytes); await using var outFileStream = new FileStream(destinationPath, FileMode.Create); await browserUploadStream.CopyToAsync(outFileStream); // Create the new entity newPhoto = new FilePathListing() { Path = destinationPath, OriginalName = Path.GetFileName(_mainPhotoFile.Name) }; // Add it to the context so it's tracked context.ArtifactFilePaths.Add(newPhoto); // We update the model's reference just in case BlogPostModel.MainPhoto = newPhoto; Snackbar.Add($"Uploaded {_mainPhotoFile.Name}", Severity.Success); } catch (Exception ex) { Snackbar.Add($"Error uploading file {_mainPhotoFile.Name}: {ex.Message}", Severity.Error); return; } } else if (isCreatingNewPost) { // Case: Creating a new post, but no file was provided. This is an error. Snackbar.Add("A main photo is required", Severity.Warning); return; } // --- 2. Database Save Logic --- BlogPostModel.ModifiedTime = DateTime.UtcNow; BlogPostModel.ArtifactGroupings = await _artifactGroupingTable.SelectedItems(); foreach (ArtifactGrouping grouping in BlogPostModel.ArtifactGroupings) { // This tells EF "this object already exists in the database." // It will attach it in an "Unchanged" state without // causing conflicts with its related entities. context.Entry(grouping).State = EntityState.Unchanged; } if (isCreatingNewPost) { BlogPostModel.CreationTime = DateTime.UtcNow; await DeDuplicatePostTags(BlogPostModel, context); BuildBlogSearchIndex(BlogPostModel); // Assign the new (and tracked) photo to the new post BlogPostModel.MainPhoto = newPhoto!; context.BlogPosts.Add(BlogPostModel); } else { // --- 3. Update Existing Post --- BlogPost? existing = await context.BlogPosts .Include(p => p.Tags) .Include(p => p.MainPhoto) // Load the existing photo .Include(p=>p.ArtifactGroupings) .FirstOrDefaultAsync(p => p.Id == BlogPostModel.Id); if (existing is null) { Snackbar.Add($"Unable to find a post with id {BlogPostModel.Id} in the database, cannot update post!", Severity.Error); return; } await DeDuplicatePostTags(BlogPostModel, context); existing.Title = BlogPostModel.Title; existing.Content = BlogPostModel.Content; existing.ModifiedTime = BlogPostModel.ModifiedTime; existing.ArtifactGroupings = BlogPostModel.ArtifactGroupings; // --- 4. Handle Photo Swap (THIS IS THE FIX) --- if (newPhoto is not null) { // A new photo was uploaded. // Save the *tracked* old photo for deletion later. if (existing.MainPhoto is not null) { oldPhotoToDelete = existing.MainPhoto; } // Assign the *new, tracked* photo. existing.MainPhoto = newPhoto; } // If newPhoto is null, we do nothing. existing.MainPhoto remains unchanged. // This avoids the tracking conflict. existing.Tags.Clear(); foreach (var tag in BlogPostModel.Tags) { existing.Tags.Add(tag); } BuildBlogSearchIndex(existing); } // --- 5. Save Primary Changes --- await context.SaveChangesAsync(); // --- 6. Old File Cleanup --- if (oldPhotoToDelete is not null) { // Now that the DB save is successful, delete the old file try { if (File.Exists(oldPhotoToDelete.Path)) { File.Delete(oldPhotoToDelete.Path); } // And remove its record (which is tracked) from the database context.ArtifactFilePaths.Remove(oldPhotoToDelete); await context.SaveChangesAsync(); } catch (Exception ex) { Snackbar.Add($"Error deleting old photo: {ex.Message}", Severity.Error); } } Snackbar.Add("Changes saved!", Severity.Success); // --- 7. Reset Page --- BlogPostModel = new(); _mainPhotoFile = null; // We can't navigate yet, we need to clear the groupings await _artifactGroupingTable.SetSelectedItemsAsync(new List()); StateHasChanged(); NavigationManager.NavigateTo($"/admin/blogstable"); } public void HandleTagsEnter(KeyboardEventArgs args) { if (args.Key == "Enter") { var newTag = new BlogPostTag() { Name = _tagsInputValue }; _tagsInputValue = ""; BlogPostModel.Tags.Add(newTag); StateHasChanged(); } } }