mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-10 09:19:24 +01:00
parent
d6d14b4170
commit
e0a0406825
14 changed files with 230 additions and 61 deletions
|
@ -25,5 +25,6 @@ namespace BTCPayServer.Abstractions.Contracts
|
|||
public string Body { get; set; }
|
||||
public string ActionLink { get; set; }
|
||||
public bool Seen { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ using System;
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
|
@ -23,7 +22,6 @@ namespace BTCPayServer.Data
|
|||
public byte[] Blob { get; set; }
|
||||
public string Blob2 { get; set; }
|
||||
|
||||
|
||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||
{
|
||||
builder.Entity<NotificationData>()
|
||||
|
|
|
@ -2461,7 +2461,7 @@ namespace BTCPayServer.Tests
|
|||
var ctrl = acc.GetController<UINotificationsController>();
|
||||
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.True(vm.Skip == 0);
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models.NotificationViewModels;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -23,35 +19,59 @@ namespace BTCPayServer.Controllers
|
|||
[Route("notifications/{action:lowercase=Index}")]
|
||||
public class UINotificationsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _factory;
|
||||
private readonly StoreRepository _storeRepo;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly NotificationManager _notificationManager;
|
||||
|
||||
public UINotificationsController(
|
||||
StoreRepository storeRepo,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
NotificationManager notificationManager)
|
||||
NotificationManager notificationManager,
|
||||
ApplicationDbContextFactory factory)
|
||||
{
|
||||
_storeRepo = storeRepo;
|
||||
_userManager = userManager;
|
||||
_notificationManager = notificationManager;
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[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))
|
||||
return RedirectToAction("Index", "UIHome");
|
||||
|
||||
var res = await _notificationManager.GetNotifications(new NotificationsQuery()
|
||||
{
|
||||
Skip = skip,
|
||||
Take = count,
|
||||
UserId = userId
|
||||
});
|
||||
var stores = await _storeRepo.GetStoresByUserId(userId);
|
||||
model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)]
|
||||
public async Task<IActionResult> FlipRead(string id)
|
||||
|
|
|
@ -237,7 +237,7 @@ namespace BTCPayServer.HostedServices
|
|||
if (InvoiceEventNotification.HandlesEvent(b.Name))
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
using System.Collections.Generic;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer.Models.NotificationViewModels
|
||||
{
|
||||
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 class NotificationIndexViewModel : IndexViewModel
|
||||
{
|
||||
public List<StoreData> Stores { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
|
|||
{
|
||||
vm.Identifier = notification.Identifier;
|
||||
vm.Type = notification.NotificationType;
|
||||
vm.StoreId = notification.StoreId;
|
||||
vm.Body =
|
||||
"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),
|
||||
|
|
|
@ -44,6 +44,7 @@ internal class InviteAcceptedNotification : BaseNotification
|
|||
{
|
||||
vm.Identifier = notification.Identifier;
|
||||
vm.Type = notification.NotificationType;
|
||||
vm.StoreId = notification.StoreId;
|
||||
vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}.";
|
||||
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
|
||||
"UIStores",
|
||||
|
|
|
@ -54,6 +54,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
|
|||
}
|
||||
vm.Identifier = notification.Identifier;
|
||||
vm.Type = notification.NotificationType;
|
||||
vm.StoreId = notification?.StoreId;
|
||||
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice),
|
||||
"UIInvoice",
|
||||
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;
|
||||
Event = invoiceEvent;
|
||||
StoreId = storeId;
|
||||
}
|
||||
|
||||
public static bool HandlesEvent(string invoiceEvent)
|
||||
|
@ -77,6 +79,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
|
|||
|
||||
public string InvoiceId { 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 NotificationType => TYPE;
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
|
|||
{
|
||||
vm.Identifier = notification.Identifier;
|
||||
vm.Type = notification.NotificationType;
|
||||
vm.StoreId = notification.StoreId;
|
||||
vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch
|
||||
{
|
||||
PayoutState.AwaitingApproval => "A new payout is awaiting for approval",
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Services.Notifications
|
||||
{
|
||||
|
@ -28,7 +25,6 @@ namespace BTCPayServer.Services.Notifications
|
|||
_handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
|
||||
}
|
||||
|
||||
|
||||
public async Task<(List<NotificationViewModel> Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly)
|
||||
{
|
||||
var cacheKey = GetNotificationsCacheId(userId);
|
||||
|
@ -68,7 +64,7 @@ namespace BTCPayServer.Services.Notifications
|
|||
|
||||
var queryables = GetNotificationsQueryable(dbContext, query);
|
||||
var items = (await queryables.withPaging.ToListAsync()).Select(ToViewModel).Where(model => model != null).ToList();
|
||||
|
||||
items = FilterNotifications(items, query);
|
||||
int? count = null;
|
||||
if (query.Seen is false)
|
||||
{
|
||||
|
@ -134,6 +130,34 @@ namespace BTCPayServer.Services.Notifications
|
|||
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)
|
||||
{
|
||||
await using var dbContext = _factory.CreateContext();
|
||||
|
@ -195,5 +219,8 @@ namespace BTCPayServer.Services.Notifications
|
|||
public int? Skip { get; set; }
|
||||
public int? Take { get; set; }
|
||||
public bool? Seen { get; set; }
|
||||
public string SearchText { get; set; }
|
||||
public string[] Type { get; set; }
|
||||
public string[] Stores { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
await using var ctx = _contextFactory.CreateContext();
|
||||
|
|
|
@ -1,38 +1,137 @@
|
|||
@model BTCPayServer.Models.NotificationViewModels.IndexViewModel
|
||||
@model BTCPayServer.Models.NotificationViewModels.NotificationIndexViewModel
|
||||
@{
|
||||
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>
|
||||
<a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary">
|
||||
<vc:icon symbol="settings" />
|
||||
<a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary d-flex align-items-center">
|
||||
<vc:icon symbol="nav-store-settings" />
|
||||
</a>
|
||||
</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)
|
||||
{
|
||||
<form method="post" asp-action="MassAction">
|
||||
@if (Model.Items.Any())
|
||||
{
|
||||
<div class="table-responsive-md">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mass-action">
|
||||
<thead class="mass-action-head">
|
||||
<tr>
|
||||
<th class="mass-action-select-col only-for-js">
|
||||
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select-all" />
|
||||
</th>
|
||||
<th>Message</th>
|
||||
<th class="date-col">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
<th>Message</th>
|
||||
<th></th>
|
||||
<th class="text-end text-success">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead class="mass-action-actions">
|
||||
|
@ -40,30 +139,31 @@
|
|||
<th class="mass-action-select-col only-for-js">
|
||||
<input type="checkbox" class="form-check-input mass-action-select-all" />
|
||||
</th>
|
||||
<th colspan="6">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div>
|
||||
<strong class="mass-action-selected-count">0</strong>
|
||||
selected
|
||||
</div>
|
||||
<th colspan="3">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-start gap-3">
|
||||
<div class="d-inline-flex align-items-center gap-3">
|
||||
<button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
|
||||
<vc:icon symbol="actions-show" />
|
||||
Mark seen
|
||||
</button>
|
||||
<button type="submit" name="command" value="mark-unseen" class="btn btn-link gap-1">
|
||||
<vc:icon symbol="actions-hide" />
|
||||
Mark unseen
|
||||
</button>
|
||||
@if (Model.Status == "Unread")
|
||||
{
|
||||
<button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
|
||||
<vc:icon symbol="actions-show" />
|
||||
Mark as seen
|
||||
</button>
|
||||
}
|
||||
<button type="submit" name="command" value="delete" class="btn btn-link gap-1">
|
||||
<vc:icon symbol="actions-trash" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div class="vr"></div>
|
||||
<div>
|
||||
<strong class="mass-action-selected-count">0</strong>
|
||||
selected
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
|
@ -71,19 +171,23 @@
|
|||
<td class="only-for-js mass-action-select-col">
|
||||
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@item.Id" />
|
||||
</td>
|
||||
<td class="date-col">@item.Created.ToBrowserDate()</td>
|
||||
<td>
|
||||
@item.Body
|
||||
</td>
|
||||
<td class="date-col fw-normal">@item.Created.ToTimeAgo()</td>
|
||||
|
||||
<td class="text-end">
|
||||
<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> <span class="seen-text"></span>
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(item.ActionLink))
|
||||
{
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -103,20 +207,15 @@ else
|
|||
</p>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
.notification-row.loading {
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.seen-text::after {
|
||||
content: "seen";
|
||||
}
|
||||
|
||||
tr.seen td .seen-text::after {
|
||||
content: "unseen";
|
||||
}
|
||||
|
||||
tr td {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
@ -128,6 +128,11 @@ a.unobtrusive-link {
|
|||
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. */
|
||||
.info-note {
|
||||
display: inline-flex;
|
||||
|
|
Loading…
Add table
Reference in a new issue