Improved Notifications List View (#6050, #3871)

This commit is contained in:
Chukwuleta Tobechi 2024-07-02 09:55:54 +01:00 committed by GitHub
parent d6d14b4170
commit e0a0406825
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 230 additions and 61 deletions

View file

@ -25,5 +25,6 @@ namespace BTCPayServer.Abstractions.Contracts
public string Body { get; set; } public string Body { get; set; }
public string ActionLink { get; set; } public string ActionLink { get; set; }
public bool Seen { get; set; } public bool Seen { get; set; }
public string StoreId { get; set; }
} }
} }

View file

@ -2,7 +2,6 @@ using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
@ -23,7 +22,6 @@ namespace BTCPayServer.Data
public byte[] Blob { get; set; } public byte[] Blob { get; set; }
public string Blob2 { get; set; } public string Blob2 { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{ {
builder.Entity<NotificationData>() builder.Entity<NotificationData>()

View file

@ -2461,7 +2461,7 @@ namespace BTCPayServer.Tests
var ctrl = acc.GetController<UINotificationsController>(); var ctrl = acc.GetController<UINotificationsController>();
var newVersion = MockVersionFetcher.MOCK_NEW_VERSION; var newVersion = MockVersionFetcher.MOCK_NEW_VERSION;
var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>( var vm = Assert.IsType<Models.NotificationViewModels.NotificationIndexViewModel>(
Assert.IsType<ViewResult>(await ctrl.Index()).Model); Assert.IsType<ViewResult>(await ctrl.Index()).Model);
Assert.True(vm.Skip == 0); Assert.True(vm.Skip == 0);

View file

@ -1,17 +1,13 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Models.NotificationViewModels; using BTCPayServer.Models.NotificationViewModels;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -23,35 +19,59 @@ namespace BTCPayServer.Controllers
[Route("notifications/{action:lowercase=Index}")] [Route("notifications/{action:lowercase=Index}")]
public class UINotificationsController : Controller public class UINotificationsController : Controller
{ {
private readonly ApplicationDbContextFactory _factory;
private readonly StoreRepository _storeRepo;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly NotificationManager _notificationManager; private readonly NotificationManager _notificationManager;
public UINotificationsController( public UINotificationsController(
StoreRepository storeRepo,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
NotificationManager notificationManager) NotificationManager notificationManager,
ApplicationDbContextFactory factory)
{ {
_storeRepo = storeRepo;
_userManager = userManager; _userManager = userManager;
_notificationManager = notificationManager; _notificationManager = notificationManager;
_factory = factory;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Index(int skip = 0, int count = 50, int timezoneOffset = 0) public async Task<IActionResult> Index(NotificationIndexViewModel model = null)
{ {
model ??= new NotificationIndexViewModel { Skip = 0 };
var timezoneOffset = model.TimezoneOffset ?? 0;
model.Status ??= "Unread";
ViewBag.Status = model.Status;
if (!ValidUserClaim(out var userId)) if (!ValidUserClaim(out var userId))
return RedirectToAction("Index", "UIHome"); return RedirectToAction("Index", "UIHome");
var res = await _notificationManager.GetNotifications(new NotificationsQuery() var stores = await _storeRepo.GetStoresByUserId(userId);
{ model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList();
Skip = skip,
Take = count,
UserId = userId
});
var model = new IndexViewModel() { Skip = skip, Count = count, Items = res.Items, Total = res.Count };
await using var dbContext = _factory.CreateContext();
var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
var fs = new SearchString(searchTerm, timezoneOffset);
model.Search = fs;
var res = await _notificationManager.GetNotifications(new NotificationsQuery
{
Skip = model.Skip,
Take = model.Count,
UserId = userId,
SearchText = model.SearchText,
Type = fs.GetFilterArray("type"),
Stores = fs.GetFilterArray("store"),
Seen = model.Status == "Unread" ? false : null
});
model.Items = res.Items;
return View(model); return View(model);
} }
[HttpPost] [HttpPost]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)]
public async Task<IActionResult> FlipRead(string id) public async Task<IActionResult> FlipRead(string id)

View file

@ -237,7 +237,7 @@ namespace BTCPayServer.HostedServices
if (InvoiceEventNotification.HandlesEvent(b.Name)) if (InvoiceEventNotification.HandlesEvent(b.Name))
{ {
await _notificationSender.SendNotification(new StoreScope(b.Invoice.StoreId), await _notificationSender.SendNotification(new StoreScope(b.Invoice.StoreId),
new InvoiceEventNotification(b.Invoice.Id, b.Name)); new InvoiceEventNotification(b.Invoice.Id, b.Name, b.Invoice.StoreId));
} }
if (b.Name == InvoiceEvent.Created) if (b.Name == InvoiceEvent.Created)
{ {

View file

@ -1,12 +1,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
namespace BTCPayServer.Models.NotificationViewModels namespace BTCPayServer.Models.NotificationViewModels
{ {
public class IndexViewModel : BasePagingViewModel public class IndexViewModel : BasePagingViewModel
{ {
public List<NotificationViewModel> Items { get; set; } public List<NotificationViewModel> Items { get; set; } = [];
public string SearchText { get; set; }
public string Status { get; set; }
public SearchString Search { get; set; }
public override int CurrentPageCount => Items.Count; public override int CurrentPageCount => Items.Count;
} }
public class NotificationIndexViewModel : IndexViewModel
{
public List<StoreData> Stores { get; set; }
}
} }

View file

@ -36,6 +36,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
{ {
vm.Identifier = notification.Identifier; vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType; vm.Type = notification.NotificationType;
vm.StoreId = notification.StoreId;
vm.Body = vm.Body =
"A payment that was made to an approved payout by an external wallet is waiting for your confirmation."; "A payment that was made to an approved payout by an external wallet is waiting for your confirmation.";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts), vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts),

View file

@ -44,6 +44,7 @@ internal class InviteAcceptedNotification : BaseNotification
{ {
vm.Identifier = notification.Identifier; vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType; vm.Type = notification.NotificationType;
vm.StoreId = notification.StoreId;
vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}."; vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}.";
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers), vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
"UIStores", "UIStores",

View file

@ -54,6 +54,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
} }
vm.Identifier = notification.Identifier; vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType; vm.Type = notification.NotificationType;
vm.StoreId = notification?.StoreId;
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice), vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice),
"UIInvoice", "UIInvoice",
new { invoiceId = notification.InvoiceId }, _options.RootPath); new { invoiceId = notification.InvoiceId }, _options.RootPath);
@ -64,10 +65,11 @@ namespace BTCPayServer.Services.Notifications.Blobs
{ {
} }
public InvoiceEventNotification(string invoiceId, string invoiceEvent) public InvoiceEventNotification(string invoiceId, string invoiceEvent, string storeId)
{ {
InvoiceId = invoiceId; InvoiceId = invoiceId;
Event = invoiceEvent; Event = invoiceEvent;
StoreId = storeId;
} }
public static bool HandlesEvent(string invoiceEvent) public static bool HandlesEvent(string invoiceEvent)
@ -77,6 +79,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
public string InvoiceId { get; set; } public string InvoiceId { get; set; }
public string Event { get; set; } public string Event { get; set; }
public string StoreId { get; set; }
public override string Identifier => Event is null ? TYPE : Event.ToStringLowerInvariant(); public override string Identifier => Event is null ? TYPE : Event.ToStringLowerInvariant();
public override string NotificationType => TYPE; public override string NotificationType => TYPE;
} }

View file

@ -36,6 +36,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
{ {
vm.Identifier = notification.Identifier; vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType; vm.Type = notification.NotificationType;
vm.StoreId = notification.StoreId;
vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch
{ {
PayoutState.AwaitingApproval => "A new payout is awaiting for approval", PayoutState.AwaitingApproval => "A new payout is awaiting for approval",

View file

@ -1,14 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data; using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications namespace BTCPayServer.Services.Notifications
{ {
@ -28,7 +25,6 @@ namespace BTCPayServer.Services.Notifications
_handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType); _handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
} }
public async Task<(List<NotificationViewModel> Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly) public async Task<(List<NotificationViewModel> Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly)
{ {
var cacheKey = GetNotificationsCacheId(userId); var cacheKey = GetNotificationsCacheId(userId);
@ -68,7 +64,7 @@ namespace BTCPayServer.Services.Notifications
var queryables = GetNotificationsQueryable(dbContext, query); var queryables = GetNotificationsQueryable(dbContext, query);
var items = (await queryables.withPaging.ToListAsync()).Select(ToViewModel).Where(model => model != null).ToList(); var items = (await queryables.withPaging.ToListAsync()).Select(ToViewModel).Where(model => model != null).ToList();
items = FilterNotifications(items, query);
int? count = null; int? count = null;
if (query.Seen is false) if (query.Seen is false)
{ {
@ -134,6 +130,34 @@ namespace BTCPayServer.Services.Notifications
return (queryable, queryable2); return (queryable, queryable2);
} }
private List<NotificationViewModel> FilterNotifications(List<NotificationViewModel> notifications, NotificationsQuery query)
{
if (!string.IsNullOrEmpty(query.SearchText))
{
notifications = notifications.Where(data => data.Body.Contains(query.SearchText)).ToList();
}
if (query.Type?.Length > 0)
{
if (query.Type?.Length > 0)
{
if (query.Type.Contains("userupdate"))
{
notifications = notifications.Where(n => n.Type.Equals("inviteaccepted", StringComparison.OrdinalIgnoreCase) ||
n.Type.Equals("userapproval", StringComparison.OrdinalIgnoreCase)).ToList();
}
else
{
notifications = notifications.Where(n => query.Type.Contains(n.Type, StringComparer.OrdinalIgnoreCase)).ToList();
}
}
}
if (query.Stores?.Length > 0)
{
notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.Stores.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList();
}
return notifications;
}
public async Task<List<NotificationViewModel>> ToggleSeen(NotificationsQuery notificationsQuery, bool? setSeen) public async Task<List<NotificationViewModel>> ToggleSeen(NotificationsQuery notificationsQuery, bool? setSeen)
{ {
await using var dbContext = _factory.CreateContext(); await using var dbContext = _factory.CreateContext();
@ -195,5 +219,8 @@ namespace BTCPayServer.Services.Notifications
public int? Skip { get; set; } public int? Skip { get; set; }
public int? Take { get; set; } public int? Take { get; set; }
public bool? Seen { get; set; } public bool? Seen { get; set; }
public string SearchText { get; set; }
public string[] Type { get; set; }
public string[] Stores { get; set; }
} }
} }

View file

@ -58,6 +58,11 @@ namespace BTCPayServer.Services.Notifications
} }
} }
public BaseNotification GetBaseNotification(NotificationData notificationData)
{
return notificationData.HasTypedBlob<BaseNotification>().GetBlob();
}
private async Task<string[]> GetUsers(INotificationScope scope, string notificationIdentifier) private async Task<string[]> GetUsers(INotificationScope scope, string notificationIdentifier)
{ {
await using var ctx = _contextFactory.CreateContext(); await using var ctx = _contextFactory.CreateContext();

View file

@ -1,38 +1,137 @@
@model BTCPayServer.Models.NotificationViewModels.IndexViewModel @model BTCPayServer.Models.NotificationViewModels.NotificationIndexViewModel
@{ @{
ViewData["Title"] = "Notifications"; ViewData["Title"] = "Notifications";
string status = ViewBag.Status;
var statusFilterCount = CountArrayFilter("type");
var storesFilterCount = CountArrayFilter("store");
} }
<partial name="_StatusMessage" /> @functions
{
private int CountArrayFilter(string type) =>
Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
<div class="d-flex flex-wrap align-items-center justify-content-between mb-2"> private bool HasArrayFilter(string type, string key = null) =>
Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
}
@section PageHeadContent
{
<style>
.dropdown > .btn {
min-width: 7rem;
padding-left: 1rem;
text-align: left;
}
@@media (max-width: 568px) {
#SearchText {
width: 100%;
}
}
</style>
}
<div class="sticky-header">
<h2 class="my-1">@ViewData["Title"]</h2> <h2 class="my-1">@ViewData["Title"]</h2>
<a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary"> <a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary d-flex align-items-center">
<vc:icon symbol="settings" /> <vc:icon symbol="nav-store-settings" />
</a> </a>
</div> </div>
<partial name="_StatusMessage" />
<form asp-route-status="@Model.Status" class="d-flex flex-wrap align-items-center gap-md-4 gap-sm-0 mb-4 col-xxl-8" asp-action="Index" method="get">
<input asp-for="Count" type="hidden" />
<input asp-for="TimezoneOffset" type="hidden" />
<input type="hidden" asp-for="Status" value="@Model.Status" />
<div class="col-12 col-md-6 col-lg-4 mb-3 mb-md-0">
<input asp-for="SearchText" class="form-control" placeholder="Search…" />
</div>
<div class="btn-group col-12 col-md-auto mb-3 mb-md-0" role="group" aria-label="View Notification">
<a class="btn @((status == "All") ? "btn-primary" : "btn-outline-secondary")"
asp-controller="UINotifications"
asp-action="Index"
asp-route-Status="All">
All
</a>
<a class="btn @((status == "Unread") ? "btn-primary" : "btn-outline-secondary")"
asp-controller="UINotifications"
asp-action="Index"
asp-route-Status="Unread">
Unread
</a>
</div>
<div class="dropdown col-12 col-md-auto mb-3 mb-md-0">
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (statusFilterCount > 0)
{
<span>@statusFilterCount Type</span>
}
else
{
<span>All Type</span>
}
</button>
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "invoicestate")" class="dropdown-item @(HasArrayFilter("type", "invoicestate") ? "custom-active" : "")">Invoice</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "payout")" class="dropdown-item @(HasArrayFilter("type", "payout") ? "custom-active" : "")">Payouts</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "newversion")" class="dropdown-item @(HasArrayFilter("type", "newversion") ? "custom-active" : "")">New Version</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "pluginupdate")" class="dropdown-item @(HasArrayFilter("type", "pluginupdate") ? "custom-active" : "")">Plugin Updates</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "userupdate")" class="dropdown-item @(HasArrayFilter("type", "userupdate") ? "custom-active" : "")">User Updates</a>
</div>
</div>
<div class="dropdown col-12 col-md-auto">
<button id="StoresOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (storesFilterCount > 0)
{
<span>@storesFilterCount Store</span>
}
else
{
<span>All Stores</span>
}
</button>
<div class="dropdown-menu" aria-labelledby="StoresOptionsToggle">
@foreach (var store in Model.Stores)
{
<a asp-action="Index"
asp-route-count="@Model.Count"
asp-route-status="@Model.Status"
asp-route-searchTerm="@Model.Search.Toggle("store", store.Id)"
class="dropdown-item @(HasArrayFilter("store", store.Id) ? "custom-active" : "")">
@store.StoreName
</a>
}
</div>
</div>
</form>
@if (Model.Items.Count > 0) @if (Model.Items.Count > 0)
{ {
<form method="post" asp-action="MassAction"> <form method="post" asp-action="MassAction">
@if (Model.Items.Any()) @if (Model.Items.Any())
{ {
<div class="table-responsive-md"> <div class="table-responsive">
<table class="table table-hover mass-action"> <table class="table table-hover mass-action">
<thead class="mass-action-head"> <thead class="mass-action-head">
<tr> <tr>
<th class="mass-action-select-col only-for-js"> <th class="mass-action-select-col only-for-js">
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select-all" /> <input name="selectedItems" type="checkbox" class="form-check-input mass-action-select-all" />
</th> </th>
<th>Message</th>
<th class="date-col"> <th class="date-col">
<div class="d-flex align-items-center gap-1"> <div class="d-flex align-items-center gap-1">
Date Date
<button type="button" class="btn btn-link p-0 switch-time-format" title="Switch date format"> <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="Switch date format">
<vc:icon symbol="time" /> <vc:icon symbol="time" />
</button> </button>
</div> </div>
</th> </th>
<th>Message</th> <th class="text-end text-success">Actions</th>
<th></th>
</tr> </tr>
</thead> </thead>
<thead class="mass-action-actions"> <thead class="mass-action-actions">
@ -40,30 +139,31 @@
<th class="mass-action-select-col only-for-js"> <th class="mass-action-select-col only-for-js">
<input type="checkbox" class="form-check-input mass-action-select-all" /> <input type="checkbox" class="form-check-input mass-action-select-all" />
</th> </th>
<th colspan="6"> <th colspan="3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3"> <div class="d-flex flex-wrap align-items-center justify-content-start gap-3">
<div>
<strong class="mass-action-selected-count">0</strong>
selected
</div>
<div class="d-inline-flex align-items-center gap-3"> <div class="d-inline-flex align-items-center gap-3">
@if (Model.Status == "Unread")
{
<button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1"> <button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
<vc:icon symbol="actions-show" /> <vc:icon symbol="actions-show" />
Mark seen Mark as seen
</button>
<button type="submit" name="command" value="mark-unseen" class="btn btn-link gap-1">
<vc:icon symbol="actions-hide" />
Mark unseen
</button> </button>
}
<button type="submit" name="command" value="delete" class="btn btn-link gap-1"> <button type="submit" name="command" value="delete" class="btn btn-link gap-1">
<vc:icon symbol="actions-trash" /> <vc:icon symbol="actions-trash" />
Delete Delete
</button> </button>
</div> </div>
<div class="vr"></div>
<div>
<strong class="mass-action-selected-count">0</strong>
selected
</div>
</div> </div>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var item in Model.Items) @foreach (var item in Model.Items)
{ {
@ -71,19 +171,23 @@
<td class="only-for-js mass-action-select-col"> <td class="only-for-js mass-action-select-col">
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@item.Id" /> <input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@item.Id" />
</td> </td>
<td class="date-col">@item.Created.ToBrowserDate()</td>
<td> <td>
@item.Body @item.Body
</td> </td>
<td class="date-col fw-normal">@item.Created.ToTimeAgo()</td>
<td class="text-end"> <td class="text-end">
<div class="d-inline-flex align-items-center gap-3"> <div class="d-inline-flex align-items-center gap-3">
<button class="btn btn-link p-0 btn-toggle-seen" type="submit" name="command" value="flip-individual:@(item.Id)">
<span>Mark</span>&nbsp;<span class="seen-text"></span>
</button>
@if (!string.IsNullOrEmpty(item.ActionLink)) @if (!string.IsNullOrEmpty(item.ActionLink))
{ {
<a href="@item.ActionLink" class="btn btn-link p-0" rel="noreferrer noopener">Details</a> <a href="@item.ActionLink" class="btn btn-link p-0" rel="noreferrer noopener">Details</a>
} }
@if (!item.Seen)
{
<button class="btn btn-link p-0 btn-toggle-seen text-nowrap" type="submit" name="command" value="flip-individual:@(item.Id)">
<span>Mark as seen</span>
</button>
}
</div> </div>
</td> </td>
</tr> </tr>
@ -103,20 +207,15 @@ else
</p> </p>
} }
<style> <style>
.notification-row.loading { .notification-row.loading {
cursor: wait; cursor: wait;
pointer-events: none; pointer-events: none;
} }
.seen-text::after {
content: "seen";
}
tr.seen td .seen-text::after {
content: "unseen";
}
tr td { tr td {
font-weight: bold; font-weight: bold;
} }

View file

@ -128,6 +128,11 @@ a.unobtrusive-link {
transform: rotate(-180deg); transform: rotate(-180deg);
} }
/* Time format button */
.switch-time-format {
line-height: 1;
}
/* Icon and text, used for warnings of additional info text. Adjust spacing and color via utility classes. */ /* Icon and text, used for warnings of additional info text. Adjust spacing and color via utility classes. */
.info-note { .info-note {
display: inline-flex; display: inline-flex;