Fixed bug where deletes of artifact groupings would not cascade
This commit is contained in:
15
OpenArchival.Blazor.Blog/ArticleNotFound.razor
Normal file
15
OpenArchival.Blazor.Blog/ArticleNotFound.razor
Normal 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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
27
OpenArchival.Blazor.Blog/BlogPostRowElement.cs
Normal file
27
OpenArchival.Blazor.Blog/BlogPostRowElement.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
139
OpenArchival.Blazor.Blog/BlogPostSearch.cs
Normal file
139
OpenArchival.Blazor.Blog/BlogPostSearch.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
52
OpenArchival.Blazor.Blog/BlogPostSearchBar.razor
Normal file
52
OpenArchival.Blazor.Blog/BlogPostSearchBar.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
219
OpenArchival.Blazor.Blog/BlogPostTable.razor
Normal file
219
OpenArchival.Blazor.Blog/BlogPostTable.razor
Normal 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>();
|
||||
}
|
||||
|
||||
}
|
||||
128
OpenArchival.Blazor.Blog/BlogPostViewer.razor
Normal file
128
OpenArchival.Blazor.Blog/BlogPostViewer.razor
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
166
OpenArchival.Blazor.Blog/SearchBlog.razor
Normal file
166
OpenArchival.Blazor.Blog/SearchBlog.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user