Fixed bug where deletes of artifact groupings would not cascade

This commit is contained in:
Vincent Allen
2025-11-12 19:10:35 -05:00
parent b34449808f
commit 9298829db6
325 changed files with 5233 additions and 20996 deletions

View File

@@ -0,0 +1,15 @@
@using MudBlazor
@page "/article-not-found"
@page "/article-not-found/{Message}"
<MudPaper Class="pa-4 ma-2 rounded" Elevation="3">
<MudText Typo="Typo.h1" Align="Align.Center">404: Article Not Found!</MudText>
@if (!string.IsNullOrEmpty(Message)) {
<MudText Typo="Typo.body1" Align="Align.Center">@Message</MudText>
}
</MudPaper>
@code {
[Parameter]
public string? Message { get; set; }
}

View File

@@ -1,20 +1,52 @@
@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}"
@layout AdminPages.AdminControlPanelLayout;
@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"
@@ -25,21 +57,28 @@
@((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>
<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;
@@ -57,13 +96,23 @@
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).Where(p => p.Id == Id).FirstOrDefault();
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)
{
@@ -76,6 +125,28 @@
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 = [];
@@ -85,7 +156,7 @@
if (existing is not null)
{
finalTags.Add(existing);
}
}
else
{
context.BlogPostTags.Add(tag);
@@ -116,53 +187,110 @@
private async Task OnPublishBlogPost(Microsoft.AspNetCore.Components.Web.MouseEventArgs args)
{
if (string.IsNullOrEmpty(BlogPostModel.Content))
{
Snackbar.Add("Content is required in the blog post, write something.", Severity.Warning);
return;
}
if (string.IsNullOrEmpty(BlogPostModel.Title))
{
Snackbar.Add("A post title is required", Severity.Warning);
return;
}
// ... (content and title checks) ...
await using var context = await ContextFactory.CreateDbContextAsync();
BlogPostModel.ModifiedTime = DateTime.UtcNow;
// Blog post does not exist in database
if (BlogPostModel.Id == 0)
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;
// Loop through the tags and de-duplicate any of them
await DeDuplicatePostTags(BlogPostModel, context);
BuildBlogSearchIndex(BlogPostModel);
// Assign the new (and tracked) photo to the new post
BlogPostModel.MainPhoto = newPhoto!;
context.BlogPosts.Add(BlogPostModel);
}
}
else
{
// Blog post exists in database
// --- 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);
// Find the existing post so we can update it
BlogPost? existing = await context.BlogPosts.Include(p=>p.Tags).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;
}
// De-duplicate tags
await DeDuplicatePostTags(BlogPostModel, context);
// Copy over the values we want
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)
@@ -173,14 +301,42 @@
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);
// Reset the model
// --- 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)
@@ -192,7 +348,8 @@
BlogPostModel.Tags.Add(newTag);
StateHasChanged();
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace OpenArchival.Blazor;
public class BlogPostRowElement
{
public int Id { get; set; }
public string Title { get; set; } = "";
public DateTime CreationTime { get; set; }
public DateTime ModifiedTime { get; set; }
public override bool Equals(object? obj)
{
return obj is BlogPostRowElement other && this.Id == other.Id;
}
public override int GetHashCode()
{
return Id.GetHashCode();
}
}

View File

@@ -0,0 +1,139 @@
/*
“Commons Clause” License Condition v1.0
https://commonsclause.com/
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
Software: Open Archival
License: GNU GPL: https://www.gnu.org/licenses/gpl-3.0.en.html
Licensor: Vincent Allen
*/
namespace OpenArchival.Blazor;
using global::OpenArchival.DataAccess;
using Microsoft.EntityFrameworkCore;
using MudBlazor;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
public enum BlogSearchFilterType
{
All,
Title,
Content,
Tags,
}
public class BlogPostSearch
{
public int TotalResults { get; set; }
public int TotalPages { get; set; }
public int PageSize { get; set; } = 20;
public int CurrentPage { get; set; } = 1;
public List<BlogPost> SearchResults { get; set; } = [];
private string _searchTerms { get; set; }
private IDbContextFactory<ApplicationDbContext> _contextFactory { get; set; }
private Expression<Func<BlogPost, bool>> _currentFilterPredicate;
private BlogSearchFilterType _selectedFilter = BlogSearchFilterType.All;
public BlogPostSearch(IDbContextFactory<ApplicationDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
public async Task Search(string Terms, BlogSearchFilterType filter, int page = 1)
{
_searchTerms = Terms;
_selectedFilter = filter;
if (string.IsNullOrEmpty(_searchTerms))
{
await using var context = await _contextFactory.CreateDbContextAsync();
TotalResults = await context.BlogPosts.CountAsync();
;
TotalPages = (int)Math.Ceiling(TotalResults / (double)PageSize);
}
else
{
// Determine which filter expression to use based on the radio button selection
switch (_selectedFilter)
{
case BlogSearchFilterType.Tags:
_currentFilterPredicate = x => x.TagsSearchVector.Matches(_searchTerms);
break;
case BlogSearchFilterType.Title:
_currentFilterPredicate = x => x.TitleSearchVector.Matches(_searchTerms);
break;
case BlogSearchFilterType.Content:
_currentFilterPredicate = x => x.ContentSearchVector.Matches(_searchTerms);
break;
case BlogSearchFilterType.All:
default:
_currentFilterPredicate = x => x.AllSearchVector.Matches(_searchTerms);
break;
}
// Get the total count using the chosen filter
await using var context = await _contextFactory.CreateDbContextAsync();
TotalResults = await context.BlogPosts.Where(_currentFilterPredicate).CountAsync();
TotalPages = (int)Math.Ceiling(TotalResults / (double)PageSize);
}
// Load the first page with the chosen filter
await LoadPageAsync(page);
}
public async Task LoadPageAsync(int page)
{
CurrentPage = page;
await using var context = await _contextFactory.CreateDbContextAsync();
if (string.IsNullOrEmpty(_searchTerms))
{
SearchResults = await context.BlogPosts
.OrderByDescending(post => post.CreationTime)
.Include(p => p.ArtifactGroupings)
.Include(p => p.MainPhoto)
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
}
else
{
SearchResults = await context.BlogPosts.Where(_currentFilterPredicate)
.Include(p => p.ArtifactGroupings)
.Include(p => p.MainPhoto)
.OrderBy(p => p.Id)
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
}
}
public void ClearResults()
{
SearchResults.Clear();
TotalResults = 0;
TotalPages = 0;
}
}

View File

@@ -0,0 +1,52 @@
@namespace OpenArchival.Blazor
@using MudBlazor
<Microsoft.AspNetCore.Components.Forms.EditForm Model="this" OnSubmit="OnSubmit">
<MudTextField FullWidth="true"
AutoFocus="string.IsNullOrEmpty(SearchTerms)"
Placeholder="Search"
T="string"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Class="mt-5"
@bind-Value="SearchTerms"
/>
<MudExpansionPanel Text="Filter...">
<MudText Typo="Typo.caption">Choose which data the serach bar will search on:</MudText>
<MudDivider></MudDivider>
<MudRadioGroup T="@BlogSearchFilterType" @bind-SelectedOption="SelectedFilter">
<MudRadio Option="BlogSearchFilterType.All" T="BlogSearchFilterType">All</MudRadio>
<MudRadio Option="BlogSearchFilterType.Title" T="BlogSearchFilterType">Title</MudRadio>
<MudRadio Option="BlogSearchFilterType.Content" T="BlogSearchFilterType">Content</MudRadio>
<MudRadio Option="BlogSearchFilterType.Tags" T="BlogSearchFilterType">Tags</MudRadio>
</MudRadioGroup>
</MudExpansionPanel>
</Microsoft.AspNetCore.Components.Forms.EditForm>
@code {
[Parameter]
public string SearchTerms { get; set; } = "";
[Parameter]
public EventCallback<string> SearchTermsChanged { get; set; }
[Parameter]
public BlogSearchFilterType SelectedFilter { get; set; } = BlogSearchFilterType.All;
[Parameter]
public EventCallback<BlogSearchFilterType> SelectedFilterChanged { get; set; }
private async Task HandleSearchKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs args)
{
if (args.Key == "Enter")
{
await SearchTermsChanged.InvokeAsync(SearchTerms);
}
}
private async Task OnSubmit(Microsoft.AspNetCore.Components.Forms.EditContext args)
{
await SearchTermsChanged.InvokeAsync(SearchTerms);
}
}

View File

@@ -0,0 +1,219 @@
@using OpenArchival.Blazor.AdminPages.Shared;
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@using MudExtensions
@using OpenArchival.Blazor.ArchiveSearch
@using OpenArchival.DataAccess
<BlogPostSearchBar @ref=_searchBar SearchTermsChanged="OnSearchTermsChanged"></BlogPostSearchBar>
<MudDataGrid T="BlogPostRowElement"
MultiSelection=true
Filterable=false
SelectOnRowClick=true
ServerData="new Func<GridState<BlogPostRowElement>, Task<GridData<BlogPostRowElement>>>(ServerReload)"
@ref=@DataGrid
@bind-SelectedItems="_selectedItems"
Comparer="_comparer">
<ToolBarContent>
<MudSpacer />
<MudButton Variant="Variant.Filled"
StartIcon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="OnDeleteClicked">Delete</MudButton>
</ToolBarContent>
<Columns>
<SelectColumn T="BlogPostRowElement"/>
<PropertyColumn
Title="Id"
Property="x=>x.Id"
Filterable="false"/>
<PropertyColumn
Title="Title"
Property="x=>x.Title"
Filterable="false"/>
<PropertyColumn
Title="Created Time"
Property="x=>x.CreationTime"
Filterable="false"/>
<PropertyColumn
Title="Modified Time"
Property="x=>x.ModifiedTime"
Filterable="false"/>
<TemplateColumn Title="Edit">
<CellTemplate>
<MudIconButton
Color="Color.Primary"
Icon="@Icons.Material.Filled.Edit"
Variant="Variant.Filled"
Href="@($"/admin/blogedit/{context.Item.Id}")"
Target="_blank">Edit</MudIconButton>
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="BlogPostRowElement"/>
</PagerContent>
</MudDataGrid>
@inject IArtifactGroupingProvider GroupingProvider;
@inject IDialogService DialogService;
@inject IArtifactGroupingProvider GroupingProvider;
@inject BlogPostSearch SearchProvider;
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
@inject ISnackbar Snackbar;
@inject NavigationManager NavigationManager;
@code
{
public class BlogPostRowElementComparer : IEqualityComparer<BlogPostRowElement>
{
public bool Equals(BlogPostRowElement? x, BlogPostRowElement? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
return x.Id == y.Id;
}
public int GetHashCode(BlogPostRowElement obj)
{
return obj.Id.GetHashCode();
}
}
private BlogPostRowElementComparer _comparer = new();
public MudDataGrid<BlogPostRowElement> DataGrid { get; set; } = default!;
private HashSet<BlogPostRowElement> _selectedItems = new();
private string _searchString { get; set; } = "";
private BlogPostSearchBar _searchBar { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
StateHasChanged();
}
private async Task OnDeleteClicked(MouseEventArgs args)
{
HashSet<BlogPostRowElement> selected = DataGrid.SelectedItems;
bool? confirmed = await DialogService.ShowMessageBox
(
new MessageBoxOptions(){
Message=$"Are you sure you want to delete {selected.Count} posts?",
Title="Delete Posts?",
CancelText="Cancel",
YesText="Delete"
});
// It was REALLY whining about null here ;-;
if (confirmed is not null && (confirmed ?? throw new ArgumentNullException("confirmed was null")))
{
await using var context = await ContextFactory.CreateDbContextAsync();
foreach (BlogPostRowElement post in selected)
{
await context.BlogPosts.Where(p => p.Id == post.Id).ExecuteDeleteAsync();
}
}
await DataGrid.ReloadServerData();
StateHasChanged();
}
private async Task<GridData<BlogPostRowElement>> ServerReload(GridState<BlogPostRowElement> state)
{
await using var context = await ContextFactory.CreateDbContextAsync();
int totalItems = await context.BlogPosts.CountAsync();
SearchProvider.PageSize = state.PageSize;
IEnumerable<BlogPost> blogPosts;
if (string.IsNullOrEmpty(_searchString))
{
blogPosts = await context.BlogPosts
.Skip(state.Page * state.PageSize)
.Take(state.PageSize)
.ToListAsync();
} else
{
await SearchProvider.Search(_searchString, _searchBar.SelectedFilter, state.Page + 1);
blogPosts = SearchProvider.SearchResults;
}
var pagedItems = blogPosts.Select(post => new BlogPostRowElement()
{
Id = post.Id,
Title = post.Title,
CreationTime = post.CreationTime,
ModifiedTime = post.ModifiedTime
});
return new GridData<BlogPostRowElement>()
{
TotalItems = totalItems,
Items = pagedItems
};
}
private async Task OnSearchTermsChanged(string args)
{
_searchString = args;
await DataGrid.ReloadServerData();
}
public async Task<List<BlogPost>> SelectedItems()
{
List<BlogPost> selectedGroupings = [];
foreach (BlogPostRowElement row in DataGrid.SelectedItems)
{
await using var context = await ContextFactory.CreateDbContextAsync();
var post = await context.BlogPosts.Where(p => p.Id == row.Id).FirstOrDefaultAsync();
if (post is null)
{
Snackbar.Add($"Post with id {row.Id} no longer exists in the database.", Severity.Error);
continue;
}
selectedGroupings.Add(post);
}
return selectedGroupings;
}
public Task SetSelectedItemsAsync(IEnumerable<BlogPost> postsToSelect)
{
var rowElementsToSelect = postsToSelect.Select(post => new BlogPostRowElement
{
Id = post.Id,
Title = post.Title,
CreationTime = post.CreationTime,
ModifiedTime = post.ModifiedTime
});
_selectedItems = new HashSet<BlogPostRowElement>(rowElementsToSelect, _comparer);
StateHasChanged();
return Task.CompletedTask;
}
public void ClearSelected()
{
_selectedItems = new HashSet<BlogPostRowElement>();
}
}

View File

@@ -0,0 +1,128 @@
@using Microsoft.EntityFrameworkCore
@using MudBlazor
@using OpenArchival.DataAccess
@page "/articles/{ArticleIdString}"
@if (_article is not null)
{
@if (_article.ArtifactGroupings.Any()) {
{
<MudPaper Class="pa-4 ma-2 rounded overflow-y-auto" Style="max-height: 400px;" Elevation="3">
<MudText Typo="Typo.h6">Related Artifacts</MudText>
<MudDivider/>
<MudGrid Justify="Justify.Center" Class="mt-4">
@foreach(ArtifactGrouping grouping in _article.ArtifactGroupings)
{
<MudItem xs="12" sm="12" md="8" lg="4">
<MudLink Href="@($"/archive/{grouping.Id}")" Target="_blank" Style="text-decoration: none; color: inherit;">
<MudCard Style="@($"height: {Math.Floor(400 * 0.7)}px; display: flex; flex-direction: column;")" >
@if (grouping.ChildArtifactEntries[0].Files.Any())
{
<MudCardMedia Image="@($"/api/files/{grouping.ChildArtifactEntries[0].Files[0].Id}")"/>
}
<MudCardContent Style=@($"flex-grow: 1; overflow-y: clip; height:{Math.Floor(400 * 0.3)}px;")>
<MudText Typo="Typo.h5">@grouping.Title</MudText>
<MudText Typo="Typo.body2">@grouping.Description</MudText>
</MudCardContent>
</MudCard>
</MudLink>
</MudItem>
}
</MudGrid>
</MudPaper>
}
}
<MudPaper Class="pa-4 ma-2 rounded" Elevation="3">
<MudGrid Justify="Justify.Center" Class="mb-2">
<MudItem>
<MudImage ObjectPosition=ObjectPosition.Center Class="rounded-1g" Src=@($"/api/files/{_article.MainPhoto.Id}") Height="400"></MudImage>
</MudItem>
</MudGrid>
<MudText Typo="Typo.h6">@_article.Title</MudText>
<MudDivider/>
<MudText Typo="Typo.caption">Posted: @_article.CreationTime.Hour:@_article.CreationTime.Minute @_article.CreationTime.Month/@_article.CreationTime.Day/@_article.CreationTime.Year</MudText>
<MudText Typo="Typo.caption">Updated: @_article.ModifiedTime.Hour:@_article.ModifiedTime.Minute @_article.ModifiedTime.Month/@_article.ModifiedTime.Day/@_article.ModifiedTime.Year</MudText>
<MudDivider/>
<MudText Typo="Typo.caption" style="font-weight: 100;">Tags:</MudText>
<OpenArchival.Blazor.CustomComponents.ChipContainer T="BlogPostTag" @bind-Items=_article.Tags DeleteEnabled=false></OpenArchival.Blazor.CustomComponents.ChipContainer>
</MudPaper>
<MudPaper Class="pa-4 ma-2 rounded" Elevation="3">
@((MarkupString)_article.Content)
</MudPaper>
}
@inject IArtifactGroupingProvider GroupingProvider;
@inject NavigationManager NavigationManager;
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
@code {
[Parameter]
public string ArticleIdString { get; set; }
/// <summary>
/// The converted grouping id from the URL
/// </summary>
private int _articleId { get; set; }
private BlogPost _article { get; set; } = default!;
protected override async Task OnParametersSetAsync()
{
if (!int.TryParse(ArticleIdString, out int articleId))
{
NavigationManager.NavigateTo("/article-not-found");
return;
}
_articleId = articleId;
await using var context = await ContextFactory.CreateDbContextAsync();
// --- FIX: Added .Include(a => a.Views) AND .FirstOrDefaultAsync(a => a.Id == _articleId)
var article = await context.BlogPosts
.Include(a => a.Views)
.Include(a => a.ArtifactGroupings)
.ThenInclude(g => g.ChildArtifactEntries)
.ThenInclude(e => e.Files)
.Include(article=>article.Views)
.Include(article=>article.Tags)
.Include(article=>article.MainPhoto)
.FirstOrDefaultAsync(a => a.Id == _articleId); // <-- FIX 2: Filter by the correct ID
if (article is null)
{
NavigationManager.NavigateTo("/article-not-found");
return;
}
_article = article;
// --- This logic will now work correctly ---
if (_article.Views is null)
{
// This article has never been viewed, create a new view count
var viewCount = new BlogPostViewCount() { Post = _article, Views = 1 };
context.Add(viewCount); // Add the new count to the context
_article.Views = viewCount; // Associate it with the article
}
else
{
// It has been viewed, just increment the count
_article.Views.Views++;
}
await context.SaveChangesAsync(); // This will now correctly INSERT or UPDATE
// StateHasChanged() is not strictly necessary here, but doesn't hurt.
StateHasChanged();
await base.OnParametersSetAsync();
}
}

View File

@@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenArchival.Blazor.AdminPages\OpenArchival.Blazor.AdminPages.csproj" />
<ProjectReference Include="..\OpenArchival.Blazor.AdminPages.Shared\OpenArchival.Blazor.AdminPages.Shared.csproj" />
<ProjectReference Include="..\OpenArchival.Blazor.Config\OpenArchival.Blazor.Config.csproj" />
<ProjectReference Include="..\OpenArchival.Blazor.CustomComponents\OpenArchival.Blazor.CustomComponents.csproj" />
<ProjectReference Include="..\OpenArchival.DataAccess\OpenArchival.DataAccess.csproj" />

View File

@@ -0,0 +1,166 @@
@page "/articles/search"
@page "/articles/search/{SearchTerms}"
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Logging
@using MudBlazor
@using OpenArchival.DataAccess
@using System.Linq
@using System.Text.RegularExpressions
@using System.Net
<BlogPostSearchBar @ref="_searchBar"
@bind-SelectedFilter="_selectedFilter"
SearchTermsChanged="PerformSearchAsyncCallback" />
@{
if (_showMostRecent && PostSearch.TotalResults > 0)
{
<MudGrid Justify="Justify.FlexStart" Class="mt-1 ml-1 mb-2">
<MudText Typo="Typo.h6" Color="Color.Primary">Most Recent Posts:</MudText>
</MudGrid>
}
else if (PostSearch.TotalResults > 0)
{
<MudGrid Justify="Justify.FlexStart" Class="mt-1 ml-1 mb-2">
<MudText Typo="Typo.subtitle2" Class="my-2">@PostSearch.TotalResults results found</MudText>
<MudButton Class="ml-1" StartIcon="@Icons.Material.Filled.Clear" OnClick="OnClearResults" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small">Clear</MudButton>
</MudGrid>
}
}
@if (PostSearch.SearchResults.Count > 0)
{
<MudGrid>
@foreach (var post in PostSearch.SearchResults)
{
<MudItem xs="12" sm="6" md="4" lg="4">
<MudLink Href="@($"/articles/{post.Id}")" Target="_blank" Style="text-decoration: none; color: inherit;">
<MudCard Style="@($"height: {Math.Floor(400 * 0.7)}px; display: flex; flex-direction: column;")">
@if (post.MainPhoto != null)
{
<MudCardMedia Image="@($"/api/files/{post.MainPhoto.Id}")" Style="height: 100%;" />
}
else
{
<MudCardMedia Image="/images/placeholder.png" Style="height: 100%;" /> }
<MudCardContent Style=@($"flex-grow: 1; overflow-y: clip; height:{Math.Floor(400 * 0.3)}px;")>
<MudText Typo="Typo.h5">@post.Title</MudText>
<MudText Typo="Typo.body2">@CreateContentSnippet(post.Content)</MudText>
</MudCardContent>
</MudCard>
</MudLink>
</MudItem>
}
</MudGrid>
<MudPaper Class="d-flex justify-center py-2 mt-4" Elevation="0">
<MudPagination Count="PostSearch.TotalPages"
Selected="PostSearch.CurrentPage"
SelectedChanged="OnPageChangedAsync" />
</MudPaper>
}
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
@inject ILogger<SearchBlog> Logger;
@inject NavigationManager NavigationManager;
@inject BlogPostSearch PostSearch;
@code {
[Parameter]
public string SearchTerms { get; set; } = "";
private BlogSearchFilterType _selectedFilter = BlogSearchFilterType.All;
private BlogPostSearchBar _searchBar = default!;
// List for the "no results" slider display
private List<SearchPageSliderEntry> _sliderEntries { get; set; } = [];
private bool _showMostRecent = true;
protected override async Task OnParametersSetAsync()
{
// This runs when the page loads, checking for search terms in the URL
if (string.IsNullOrWhiteSpace(SearchTerms))
{
await PostSearch.Search("", _selectedFilter, 1);
_showMostRecent = true;
}
else
{
await PostSearch.Search(SearchTerms, _selectedFilter, 1);
_showMostRecent = false;
}
}
/// <summary>
/// This is called by the SearchBar component's 'SearchTermsChanged' event.
/// </summary>
private async Task PerformSearchAsyncCallback(string searchTerms)
{
if (string.IsNullOrEmpty(searchTerms))
{
_showMostRecent = true;
} else
{
_showMostRecent = false;
}
SearchTerms = searchTerms; // Update the page's parameter
// Update the URL to reflect the new search
NavigationManager.NavigateTo($"/articles/search/{Uri.EscapeDataString(searchTerms)}", replace: true);
// Perform the search (always start on page 1)
await PostSearch.Search(searchTerms, _selectedFilter, 1);
StateHasChanged(); // Re-render the page with results
}
/// <summary>
/// This is called by the MudPagination's 'SelectedChanged' event.
/// </summary>
private async Task OnPageChangedAsync(int page)
{
// Load the specific page using the service
await PostSearch.LoadPageAsync(page);
StateHasChanged(); // Re-render with the new page's data
}
/// <summary>
/// Clears the search results and state.
/// </summary>
private void OnClearResults()
{
PostSearch.ClearResults();
SearchTerms = "";
// Navigate back to the base search page
NavigationManager.NavigateTo("/articles/search", replace: true);
StateHasChanged();
}
public static string CreateContentSnippet(string html, int maxLength = 150)
{
if (string.IsNullOrEmpty(html))
{
return string.Empty;
}
// Strip HTML tags
string plainText = Regex.Replace(html, @"<[^>]+>", string.Empty);
// Decode HTML entities
plainText = WebUtility.HtmlDecode(plainText).Trim();
if (plainText.Length > maxLength)
{
return plainText.Substring(0, maxLength) + "...";
}
return plainText;
}
}