Files

356 lines
12 KiB
Plaintext

@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;
<MudText Typo="Typo.h6">Create a Blog Post</MudText>
<MudDivider></MudDivider>
<MudTextField Placeholder="Title" T="string" @bind-Value=@BlogPostModel.Title></MudTextField>
<MudFileUpload T="IBrowserFile"
AppendMultipleFiles=false
Accept=".jpg,.png,.webp"
MaximumFileCount="1"
@bind-Files=_mainPhotoFile
Class="mt-4">
<ActivatorContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary">
Upload Main Photo
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText>@context.Name</MudText>
}
else if (BlogPostModel.MainPhoto != null)
{
<MudText>@BlogPostModel.MainPhoto.OriginalName</MudText>
}
else
{
<MudText>No Main Photo Selected</MudText>
}
</SelectedTemplate>
</MudFileUpload>
<MudExRichTextEdit @ref="@_contentEditor"
ReadOnly="false"
Height="444"
Class="m-2 mt-4"
Placeholder="Edit html"
ValueHtmlBehavior="MudExRichTextEditor.Types.GetHtmlBehavior.SemanticHtml"
@bind-Value="BlogPostModel.Content">
@((MarkupString)BlogPostModel.Content)
</MudExRichTextEdit>
<MudText Typo="Typo.h6">Add Tags</MudText>
<MudDivider />
<MudText Typo="Typo.caption" Color="Color.Primary">Press enter to add.</MudText>
<ChipContainer T="BlogPostTag" @ref="@_tagsChipContainer" @bind-Items="BlogPostModel.Tags">
<InputContent>
<MudAutocomplete T="string"
Placeholder="Add Tags..."
@bind-Text=_tagsInputValue
OnKeyDown="HandleTagsEnter">
</MudAutocomplete>
</InputContent>
</ChipContainer>
<MudText Typo="Typo.h6" Class="mt-4">Link Artifact Groupings</MudText>
<MudDivider />
<MudText Typo="Typo.caption" Color="Color.Primary">Select Artifact Groupings to Associate with this post.</MudText>
<ArchiveGroupingsTable @ref=_artifactGroupingTable></ArchiveGroupingsTable>
<MudGrid Justify="Justify.FlexEnd" Class="mr-4 pt-8">
<MudButton StartIcon="@Icons.Material.Filled.Publish" Color=Color.Primary Variant=Variant.Filled OnClick="OnPublishBlogPost">Publish</MudButton>
</MudGrid>
@inject IOptions<FileUploadOptions> FileOptions;
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
@inject ISnackbar Snackbar;
@inject NavigationManager NavigationManager;
@*OnKeyDown="@(ev => HandleChipContainerEnter<string>(ev, _tagsChipContainer, _tagsInputValue, () => _tagsInputValue = string.Empty))"*@
@code {
[Parameter]
public int Id { get; set; }
public List<string> StringTags { get; set; } = [];
public BlogPost BlogPostModel { get; set; } = new();
private MudExRichTextEdit _contentEditor = default!;
private string _tagsInputValue = "";
private ChipContainer<BlogPostTag> _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<BlogPostTag> 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<ArtifactGrouping>());
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();
}
}
}