Phase 15C - Feature Requests: Layout Polish, Notifications and Public Roadmap Foundation

This commit is contained in:
Nick Beckley 2026-06-23 21:51:10 +01:00
parent ac14776e45
commit 6896629ed0
16 changed files with 787 additions and 17 deletions

View File

@ -10,7 +10,8 @@ namespace PlotLine.Controllers;
[Route("admin")] [Route("admin")]
public sealed class AdminController( public sealed class AdminController(
ICurrentUserService currentUser, ICurrentUserService currentUser,
IFeatureRequestService featureRequests) : Controller IFeatureRequestService featureRequests,
IEmailService emails) : Controller
{ {
[HttpGet("")] [HttpGet("")]
[HttpGet("index")] [HttpGet("index")]
@ -78,12 +79,27 @@ public sealed class AdminController(
return BadRequest(); return BadRequest();
} }
var before = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (before is null)
{
return NotFound();
}
var updated = await featureRequests.UpdateAdminFieldsAsync(model); var updated = await featureRequests.UpdateAdminFieldsAsync(model);
if (!updated) if (!updated)
{ {
return NotFound(); return NotFound();
} }
var after = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (after is not null && !string.Equals(before.Status, after.Status, StringComparison.OrdinalIgnoreCase))
{
await emails.SendFeatureRequestStatusChangedAsync(
after,
BuildAbsoluteUrl("Details", "FeatureRequests", new { id = after.FeatureRequestID }),
currentUser.Email ?? string.Empty);
}
TempData["AdminMessage"] = "Feature request updated."; TempData["AdminMessage"] = "Feature request updated.";
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID }); return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
} }
@ -115,6 +131,23 @@ public sealed class AdminController(
} }
await featureRequests.AddAdminReplyAsync(model.FeatureRequestID, userId, model.MessageBody); await featureRequests.AddAdminReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
await emails.SendFeatureRequestAdminReplyAsync(
request,
Preview(model.MessageBody),
BuildAbsoluteUrl("Details", "FeatureRequests", new { id = request.FeatureRequestID }),
currentUser.Email ?? string.Empty);
return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID }); return RedirectToAction(nameof(FeatureRequestDetails), new { id = model.FeatureRequestID });
} }
private string BuildAbsoluteUrl(string action, string controller, object routeValues)
{
return Url.Action(action, controller, routeValues, Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}";
}
private static string Preview(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}...";
}
} }

View File

@ -8,7 +8,8 @@ namespace PlotLine.Controllers;
[Authorize] [Authorize]
public sealed class FeatureRequestsController( public sealed class FeatureRequestsController(
ICurrentUserService currentUser, ICurrentUserService currentUser,
IFeatureRequestService featureRequests) : Controller IFeatureRequestService featureRequests,
IEmailService emails) : Controller
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
@ -24,6 +25,15 @@ public sealed class FeatureRequestsController(
return View(new FeatureRequestCreateViewModel()); return View(new FeatureRequestCreateViewModel());
} }
[HttpGet]
public async Task<IActionResult> Roadmap()
{
return View(new FeatureRequestRoadmapViewModel
{
Sections = await featureRequests.ListRoadmapAsync()
});
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Create(FeatureRequestCreateViewModel model) public async Task<IActionResult> Create(FeatureRequestCreateViewModel model)
@ -47,6 +57,10 @@ public sealed class FeatureRequestsController(
return NotFound(); return NotFound();
} }
await featureRequests.MarkViewedForUserAsync(request.FeatureRequestID, request.UserID);
request.UserNeedsAttention = false;
request.UserAttentionUtc = null;
return View(new FeatureRequestDetailsViewModel return View(new FeatureRequestDetailsViewModel
{ {
Request = request, Request = request,
@ -77,6 +91,11 @@ public sealed class FeatureRequestsController(
} }
await featureRequests.AddUserReplyAsync(model.FeatureRequestID, userId, model.MessageBody); await featureRequests.AddUserReplyAsync(model.FeatureRequestID, userId, model.MessageBody);
await emails.SendFeatureRequestUserReplyAsync(
request,
Preview(model.MessageBody),
BuildAbsoluteUrl("FeatureRequestDetails", "Admin", new { id = request.FeatureRequestID }),
currentUser.Email ?? string.Empty);
return RedirectToAction(nameof(Details), new { id = model.FeatureRequestID }); return RedirectToAction(nameof(Details), new { id = model.FeatureRequestID });
} }
@ -91,4 +110,16 @@ public sealed class FeatureRequestsController(
model.ImportanceOptions = PlotLine.Models.FeatureRequestOptions.ImportanceOptions; model.ImportanceOptions = PlotLine.Models.FeatureRequestOptions.ImportanceOptions;
return model; return model;
} }
private string BuildAbsoluteUrl(string action, string controller, object routeValues)
{
return Url.Action(action, controller, routeValues, Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}";
}
private static string Preview(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 240 ? cleaned : $"{cleaned[..240]}...";
}
} }

View File

@ -11,10 +11,15 @@ public interface IFeatureRequestRepository
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId); Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId); Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort); Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(string? status, string? appArea, string? importance, string? search, string sort);
Task<IReadOnlyList<FeatureRequest>> ListRoadmapAsync();
Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate); Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate);
Task<FeatureRequest?> UpdateRoadmapVisibilityAsync(int featureRequestId, bool isPublicCandidate);
Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes); Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes);
Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody); Task<FeatureRequestMessage?> AddMessageAsync(int featureRequestId, int? userId, bool isAdmin, string messageBody);
Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId); Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId);
Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId);
Task<int> CountAttentionForUserAsync(int userId);
Task<int> CountWaitingForAdminAsync();
} }
public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFactory) : IFeatureRequestRepository public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFactory) : IFeatureRequestRepository
@ -66,6 +71,15 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<FeatureRequest>> ListRoadmapAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FeatureRequest>(
"dbo.FeatureRequest_ListRoadmap",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate) public async Task<FeatureRequest?> UpdateStatusAsync(int featureRequestId, string status, bool isPublicCandidate)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -75,6 +89,15 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<FeatureRequest?> UpdateRoadmapVisibilityAsync(int featureRequestId, bool isPublicCandidate)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<FeatureRequest>(
"dbo.FeatureRequest_UpdateRoadmapVisibility",
new { FeatureRequestID = featureRequestId, IsPublicCandidate = isPublicCandidate },
commandType: CommandType.StoredProcedure);
}
public async Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes) public async Task<FeatureRequest?> UpdateAdminNotesAsync(int featureRequestId, string? adminNotes)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -102,4 +125,30 @@ public sealed class FeatureRequestRepository(ISqlConnectionFactory connectionFac
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return rows.ToList(); return rows.ToList();
} }
public async Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.FeatureRequest_MarkViewedForUser",
new { FeatureRequestID = featureRequestId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> CountAttentionForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.FeatureRequest_CountAttentionForUser",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> CountWaitingForAdminAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.FeatureRequest_CountWaitingForAdmin",
commandType: CommandType.StoredProcedure);
}
} }

View File

@ -104,6 +104,9 @@ public sealed class FeatureRequest
public bool? LatestMessageIsAdmin { get; set; } public bool? LatestMessageIsAdmin { get; set; }
public string? LatestMessagePreview { get; set; } public string? LatestMessagePreview { get; set; }
public int MessageCount { get; set; } public int MessageCount { get; set; }
public DateTime? UserLastViewedUtc { get; set; }
public DateTime? UserAttentionUtc { get; set; }
public bool UserNeedsAttention { get; set; }
public bool IsOpen => FeatureRequestStatuses.IsOpen(Status); public bool IsOpen => FeatureRequestStatuses.IsOpen(Status);
public string StatusExplanation => FeatureRequestStatuses.Explanation(Status); public string StatusExplanation => FeatureRequestStatuses.Explanation(Status);
@ -126,6 +129,13 @@ public sealed class FeatureRequest
} }
} }
public sealed class FeatureRequestRoadmapSection
{
public string Heading { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
}
public static class FeatureRequestConversationStates public static class FeatureRequestConversationStates
{ {
public const string WaitingForAdminReply = "Waiting for Admin Reply"; public const string WaitingForAdminReply = "Waiting for Admin Reply";

View File

@ -47,6 +47,9 @@ public interface IEmailService
{ {
Task SendVerificationEmailAsync(string email, string displayName, Guid token); Task SendVerificationEmailAsync(string email, string displayName, Guid token);
Task SendPasswordResetEmailAsync(string email, string displayName, Guid token); Task SendPasswordResetEmailAsync(string email, string displayName, Guid token);
Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail);
Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail);
} }
public interface ICurrentUserService public interface ICurrentUserService

View File

@ -51,6 +51,7 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
public sealed class EmailService( public sealed class EmailService(
IOptions<EmailSettings> emailOptions, IOptions<EmailSettings> emailOptions,
IConfiguration configuration,
IEmailQueueRepository emailQueue, IEmailQueueRepository emailQueue,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<EmailService> logger) : IEmailService ILogger<EmailService> logger) : IEmailService
@ -85,8 +86,107 @@ public sealed class EmailService(
return QueueAsync(email, displayName, "Reset your PlotDirector password", plainText, html); return QueueAsync(email, displayName, "Reset your PlotDirector password", plainText, html);
} }
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string html) public Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
{ {
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
var body = $"""
Hi {DisplayName(request.UserDisplayName)},
There is a new reply on your PlotDirector feature request:
"{request.Title}"
{messagePreview}
You can view and reply here:
{link}
Thanks,
The PlotDirector team
""";
return QueueAsync(
request.UserEmail ?? string.Empty,
DisplayName(request.UserDisplayName),
$"PlotDirector feature request update: {request.Title}",
body,
html: null);
}
public Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail)
{
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
var body = $"""
Hi {DisplayName(request.UserDisplayName)},
The status of your feature request has changed:
"{request.Title}"
New status: {request.Status}
{request.StatusExplanation}
You can view the request here:
{link}
Thanks,
The PlotDirector team
""";
return QueueAsync(
request.UserEmail ?? string.Empty,
DisplayName(request.UserDisplayName),
$"PlotDirector feature request status changed: {request.Title}",
body,
html: null);
}
public async Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
{
var adminEmails = configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
foreach (var adminEmail in adminEmails
.Where(email => !string.IsNullOrWhiteSpace(email))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(email => !string.Equals(email, actorEmail, StringComparison.OrdinalIgnoreCase)))
{
var body = $"""
A user replied to a PlotDirector feature request:
"{request.Title}"
User: {request.UserDisplayName} ({request.UserEmail})
{messagePreview}
View the request here:
{link}
""";
await QueueAsync(
adminEmail,
"PlotDirector admin",
$"PlotDirector feature request reply: {request.Title}",
body,
html: null);
}
}
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
{
if (string.IsNullOrWhiteSpace(toEmail))
{
return;
}
var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
{ {
ToEmail = toEmail, ToEmail = toEmail,
@ -163,6 +263,11 @@ public sealed class EmailService(
return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim(); return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim();
} }
private static string DisplayName(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "there" : value.Trim();
}
private string GetFromAddress() private string GetFromAddress()
{ {
return string.IsNullOrWhiteSpace(_settings.FromAddress) return string.IsNullOrWhiteSpace(_settings.FromAddress)

View File

@ -11,11 +11,15 @@ public interface IFeatureRequestService
Task<FeatureRequest?> GetForAdminAsync(int featureRequestId); Task<FeatureRequest?> GetForAdminAsync(int featureRequestId);
Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId); Task<IReadOnlyList<FeatureRequest>> ListForUserAsync(int userId);
Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter); Task<IReadOnlyList<FeatureRequest>> ListForAdminAsync(AdminFeatureRequestFilter filter);
Task<IReadOnlyList<FeatureRequestRoadmapSection>> ListRoadmapAsync();
Task<FeatureRequestAdminSummary> GetAdminSummaryAsync(); Task<FeatureRequestAdminSummary> GetAdminSummaryAsync();
Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model); Task<bool> UpdateAdminFieldsAsync(AdminFeatureRequestUpdateViewModel model);
Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody); Task<bool> AddUserReplyAsync(int featureRequestId, int userId, string messageBody);
Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody); Task<bool> AddAdminReplyAsync(int featureRequestId, int adminUserId, string messageBody);
Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId); Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId);
Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId);
Task<int> CountAttentionForUserAsync(int userId);
Task<int> CountWaitingForAdminAsync();
} }
public sealed class FeatureRequestService(IFeatureRequestRepository featureRequests) : IFeatureRequestService public sealed class FeatureRequestService(IFeatureRequestRepository featureRequests) : IFeatureRequestService
@ -50,6 +54,20 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
sanitized.Sort); sanitized.Sort);
} }
public async Task<IReadOnlyList<FeatureRequestRoadmapSection>> ListRoadmapAsync()
{
var requests = await featureRequests.ListRoadmapAsync();
return
[
BuildRoadmapSection("Under Consideration", FeatureRequestStatuses.Candidate, requests),
BuildRoadmapSection("Planned", FeatureRequestStatuses.Planned, requests),
BuildRoadmapSection("In Progress", FeatureRequestStatuses.InProgress, requests),
BuildRoadmapSection("Recently Shipped", FeatureRequestStatuses.Shipped, requests
.OrderByDescending(request => request.ShippedUtc ?? request.UpdatedUtc)
.ToList())
];
}
public async Task<FeatureRequestAdminSummary> GetAdminSummaryAsync() public async Task<FeatureRequestAdminSummary> GetAdminSummaryAsync()
{ {
var requests = await featureRequests.ListForAdminAsync( var requests = await featureRequests.ListForAdminAsync(
@ -81,12 +99,24 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
return false; return false;
} }
var updated = await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate); var current = await featureRequests.GetForAdminAsync(model.FeatureRequestID);
if (updated is null) if (current is null)
{ {
return false; return false;
} }
var statusChanged = !string.Equals(current.Status, model.Status, StringComparison.OrdinalIgnoreCase);
var roadmapVisibilityChanged = current.IsPublicCandidate != model.IsPublicCandidate;
if (statusChanged)
{
await featureRequests.UpdateStatusAsync(model.FeatureRequestID, model.Status, model.IsPublicCandidate);
}
else if (roadmapVisibilityChanged)
{
await featureRequests.UpdateRoadmapVisibilityAsync(model.FeatureRequestID, model.IsPublicCandidate);
}
await featureRequests.UpdateAdminNotesAsync(model.FeatureRequestID, CleanNullable(model.AdminNotes)); await featureRequests.UpdateAdminNotesAsync(model.FeatureRequestID, CleanNullable(model.AdminNotes));
return true; return true;
} }
@ -106,6 +136,15 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
public Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId) => public Task<IReadOnlyList<FeatureRequestMessage>> ListMessagesAsync(int featureRequestId) =>
featureRequests.ListMessagesAsync(featureRequestId); featureRequests.ListMessagesAsync(featureRequestId);
public Task<bool> MarkViewedForUserAsync(int featureRequestId, int userId) =>
featureRequests.MarkViewedForUserAsync(featureRequestId, userId);
public Task<int> CountAttentionForUserAsync(int userId) =>
featureRequests.CountAttentionForUserAsync(userId);
public Task<int> CountWaitingForAdminAsync() =>
featureRequests.CountWaitingForAdminAsync();
private static string Clean(string value) => value.Trim(); private static string Clean(string value) => value.Trim();
private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string? CleanNullable(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@ -121,4 +160,16 @@ public sealed class FeatureRequestService(IFeatureRequestRepository featureReque
Sort = FeatureRequestSortOptions.IsValid(filter.Sort) ? filter.Sort : FeatureRequestSortOptions.LatestActivity Sort = FeatureRequestSortOptions.IsValid(filter.Sort) ? filter.Sort : FeatureRequestSortOptions.LatestActivity
}; };
} }
private static FeatureRequestRoadmapSection BuildRoadmapSection(string heading, string status, IEnumerable<FeatureRequest> requests)
{
return new FeatureRequestRoadmapSection
{
Heading = heading,
Status = status,
Requests = requests
.Where(request => string.Equals(request.Status, status, StringComparison.OrdinalIgnoreCase))
.ToList()
};
}
} }

View File

@ -0,0 +1,331 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.FeatureRequests', N'UserLastViewedUtc') IS NULL
BEGIN
ALTER TABLE dbo.FeatureRequests ADD UserLastViewedUtc datetime2 NULL;
END;
GO
IF COL_LENGTH(N'dbo.FeatureRequests', N'UserAttentionUtc') IS NULL
BEGIN
ALTER TABLE dbo.FeatureRequests ADD UserAttentionUtc datetime2 NULL;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FeatureRequests_UserAttention' AND object_id = OBJECT_ID(N'dbo.FeatureRequests'))
BEGIN
CREATE INDEX IX_FeatureRequests_UserAttention
ON dbo.FeatureRequests(UserID, UserAttentionUtc)
WHERE UserAttentionUtc IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForUser
@FeatureRequestID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.FeatureRequestID = @FeatureRequestID
AND fr.UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_GetForAdmin
@FeatureRequestID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
CAST(NULL AS nvarchar(max)) AS AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE fr.UserID = @UserID
ORDER BY COALESCE(fr.UserAttentionUtc, latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) DESC, fr.FeatureRequestID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListForAdmin
@Status nvarchar(50) = NULL,
@AppArea nvarchar(100) = NULL,
@Importance nvarchar(50) = NULL,
@Search nvarchar(200) = NULL,
@Sort nvarchar(50) = N'LatestActivity'
AS
BEGIN
SET NOCOUNT ON;
DECLARE @CleanSearch nvarchar(200) = NULLIF(LTRIM(RTRIM(@Search)), N'');
SELECT fr.FeatureRequestID, fr.UserID, au.Email AS UserEmail, au.DisplayName AS UserDisplayName,
fr.Title, fr.Description, fr.AppArea, fr.Importance, fr.Status, fr.IsPublicCandidate,
fr.AdminNotes, fr.CreatedUtc, fr.UpdatedUtc, fr.ClosedUtc, fr.ShippedUtc,
COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) AS LatestActivityUtc,
latest.IsAdmin AS LatestMessageIsAdmin,
latest.MessageBody AS LatestMessagePreview,
messageCounts.MessageCount,
fr.UserLastViewedUtc,
fr.UserAttentionUtc,
CAST(CASE WHEN fr.UserAttentionUtc IS NOT NULL THEN 1 ELSE 0 END AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests fr
INNER JOIN dbo.AppUser au ON au.UserID = fr.UserID
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
OUTER APPLY
(
SELECT COUNT(1) AS MessageCount
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
) messageCounts
WHERE (@Status IS NULL OR fr.Status = @Status)
AND (@AppArea IS NULL OR fr.AppArea = @AppArea)
AND (@Importance IS NULL OR fr.Importance = @Importance)
AND
(
@CleanSearch IS NULL
OR fr.Title LIKE N'%' + @CleanSearch + N'%'
OR fr.Description LIKE N'%' + @CleanSearch + N'%'
OR au.DisplayName LIKE N'%' + @CleanSearch + N'%'
OR au.Email LIKE N'%' + @CleanSearch + N'%'
)
ORDER BY
CASE WHEN @Sort = N'OldestFirst' THEN fr.CreatedUtc END ASC,
CASE WHEN @Sort = N'NewestFirst' THEN fr.CreatedUtc END DESC,
CASE WHEN @Sort IS NULL OR @Sort = N'LatestActivity' THEN COALESCE(latest.CreatedUtc, fr.UpdatedUtc, fr.CreatedUtc) END DESC,
fr.FeatureRequestID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_UpdateStatus
@FeatureRequestID int,
@Status nvarchar(50),
@IsPublicCandidate bit = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET Status = @Status,
IsPublicCandidate = COALESCE(@IsPublicCandidate, IsPublicCandidate),
UpdatedUtc = sysutcdatetime(),
UserAttentionUtc = sysutcdatetime(),
ClosedUtc = CASE WHEN @Status IN (N'Shipped', N'Not Planned') THEN COALESCE(ClosedUtc, sysutcdatetime()) ELSE NULL END,
ShippedUtc = CASE WHEN @Status = N'Shipped' THEN COALESCE(ShippedUtc, sysutcdatetime()) ELSE NULL END
WHERE FeatureRequestID = @FeatureRequestID;
EXEC dbo.FeatureRequest_GetForAdmin @FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_UpdateRoadmapVisibility
@FeatureRequestID int,
@IsPublicCandidate bit
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET IsPublicCandidate = @IsPublicCandidate,
UpdatedUtc = sysutcdatetime()
WHERE FeatureRequestID = @FeatureRequestID;
EXEC dbo.FeatureRequest_GetForAdmin @FeatureRequestID = @FeatureRequestID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequestMessage_Add
@FeatureRequestID int,
@UserID int = NULL,
@IsAdmin bit = 0,
@MessageBody nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.FeatureRequests WHERE FeatureRequestID = @FeatureRequestID)
RETURN;
INSERT INTO dbo.FeatureRequestMessages (FeatureRequestID, UserID, IsAdmin, MessageBody)
VALUES (@FeatureRequestID, @UserID, @IsAdmin, @MessageBody);
UPDATE dbo.FeatureRequests
SET UpdatedUtc = sysutcdatetime(),
UserAttentionUtc = CASE WHEN @IsAdmin = 1 THEN sysutcdatetime() ELSE UserAttentionUtc END
WHERE FeatureRequestID = @FeatureRequestID;
SELECT frm.FeatureRequestMessageID, frm.FeatureRequestID, frm.UserID, au.Email AS UserEmail,
au.DisplayName AS UserDisplayName, frm.IsAdmin, frm.MessageBody, frm.CreatedUtc
FROM dbo.FeatureRequestMessages frm
LEFT JOIN dbo.AppUser au ON au.UserID = frm.UserID
WHERE frm.FeatureRequestMessageID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_MarkViewedForUser
@FeatureRequestID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.FeatureRequests
SET UserLastViewedUtc = sysutcdatetime(),
UserAttentionUtc = NULL
WHERE FeatureRequestID = @FeatureRequestID
AND UserID = @UserID;
SELECT CAST(@@ROWCOUNT AS bit) AS Succeeded;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_CountAttentionForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT COUNT(1)
FROM dbo.FeatureRequests
WHERE UserID = @UserID
AND UserAttentionUtc IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_CountWaitingForAdmin
AS
BEGIN
SET NOCOUNT ON;
SELECT COUNT(1)
FROM dbo.FeatureRequests fr
OUTER APPLY
(
SELECT TOP (1) frm.IsAdmin
FROM dbo.FeatureRequestMessages frm
WHERE frm.FeatureRequestID = fr.FeatureRequestID
ORDER BY frm.CreatedUtc DESC, frm.FeatureRequestMessageID DESC
) latest
WHERE fr.Status NOT IN (N'Shipped', N'Not Planned')
AND (latest.IsAdmin IS NULL OR latest.IsAdmin = 0);
END;
GO
CREATE OR ALTER PROCEDURE dbo.FeatureRequest_ListRoadmap
AS
BEGIN
SET NOCOUNT ON;
SELECT FeatureRequestID, Title, CAST(NULL AS int) AS UserID, CAST(NULL AS nvarchar(256)) AS UserEmail,
CAST(NULL AS nvarchar(200)) AS UserDisplayName, Description, AppArea, Importance, Status,
IsPublicCandidate, CAST(NULL AS nvarchar(max)) AS AdminNotes, CreatedUtc, UpdatedUtc,
ClosedUtc, ShippedUtc, UpdatedUtc AS LatestActivityUtc,
CAST(NULL AS bit) AS LatestMessageIsAdmin, CAST(NULL AS nvarchar(max)) AS LatestMessagePreview,
0 AS MessageCount, CAST(NULL AS datetime2) AS UserLastViewedUtc, CAST(NULL AS datetime2) AS UserAttentionUtc,
CAST(0 AS bit) AS UserNeedsAttention
FROM dbo.FeatureRequests
WHERE IsPublicCandidate = 1
AND Status IN (N'Candidate', N'Planned', N'In Progress', N'Shipped')
ORDER BY
CASE Status
WHEN N'Candidate' THEN 1
WHEN N'Planned' THEN 2
WHEN N'In Progress' THEN 3
WHEN N'Shipped' THEN 4
ELSE 5
END,
CASE WHEN Status = N'Shipped' THEN ShippedUtc END DESC,
UpdatedUtc DESC,
FeatureRequestID DESC;
END;
GO

View File

@ -8,6 +8,11 @@ public sealed class FeatureRequestIndexViewModel
public IReadOnlyList<FeatureRequest> Requests { get; set; } = []; public IReadOnlyList<FeatureRequest> Requests { get; set; } = [];
} }
public sealed class FeatureRequestRoadmapViewModel
{
public IReadOnlyList<FeatureRequestRoadmapSection> Sections { get; set; } = [];
}
public sealed class FeatureRequestCreateViewModel public sealed class FeatureRequestCreateViewModel
{ {
[Required(ErrorMessage = "Enter a title.")] [Required(ErrorMessage = "Enter a title.")]

View File

@ -94,7 +94,8 @@
<div class="form-check mb-3"> <div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="IsPublicCandidate" name="IsPublicCandidate" value="true" checked="@request.IsPublicCandidate" /> <input class="form-check-input" type="checkbox" id="IsPublicCandidate" name="IsPublicCandidate" value="true" checked="@request.IsPublicCandidate" />
<input type="hidden" name="IsPublicCandidate" value="false" /> <input type="hidden" name="IsPublicCandidate" value="false" />
<label class="form-check-label" for="IsPublicCandidate">Public candidate</label> <label class="form-check-label" for="IsPublicCandidate">Show on curated roadmap</label>
<div class="form-text">Only Candidate, Planned, In Progress and Shipped requests appear on the roadmap.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="AdminNotes">Admin notes</label> <label class="form-label" for="AdminNotes">Admin notes</label>

View File

@ -3,6 +3,7 @@
@using System.Text.Encodings.Web @using System.Text.Encodings.Web
@{ @{
ViewData["Title"] = "Feature Requests"; ViewData["Title"] = "Feature Requests";
ViewData["ShellClass"] = "plotline-shell-wide feature-request-admin-page";
} }
<div class="page-heading compact"> <div class="page-heading compact">
@ -88,7 +89,7 @@ else
{ {
<section class="edit-panel"> <section class="edit-panel">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle feature-request-admin-table">
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
@ -100,9 +101,8 @@ else
<th>Latest Activity</th> <th>Latest Activity</th>
<th>Conversation</th> <th>Conversation</th>
<th>Messages</th> <th>Messages</th>
<th>Latest message</th> <th class="feature-request-admin-table__preview">Latest message</th>
<th>Updated</th> <th class="feature-request-admin-table__action"></th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -118,8 +118,7 @@ else
<td>@FormatNullableDate(request.LatestActivityUtc)</td> <td>@FormatNullableDate(request.LatestActivityUtc)</td>
<td>@ConversationBadge(request.ConversationState)</td> <td>@ConversationBadge(request.ConversationState)</td>
<td>@request.MessageCount.ToString("N0")</td> <td>@request.MessageCount.ToString("N0")</td>
<td class="text-truncate" style="max-width: 260px;">@Preview(request.LatestMessagePreview)</td> <td class="feature-request-admin-table__preview">@Preview(request.LatestMessagePreview)</td>
<td>@FormatDate(request.UpdatedUtc)</td>
<td class="text-end"> <td class="text-end">
<a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a> <a class="btn btn-outline-primary btn-sm" asp-action="FeatureRequestDetails" asp-route-id="@request.FeatureRequestID">Open</a>
</td> </td>
@ -144,7 +143,7 @@ else
} }
var cleaned = value.ReplaceLineEndings(" ").Trim(); var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 120 ? cleaned : $"{cleaned[..120]}..."; return cleaned.Length <= 90 ? cleaned : $"{cleaned[..90]}...";
} }
private static IHtmlContent StatusBadge(string status) private static IHtmlContent StatusBadge(string status)

View File

@ -12,6 +12,7 @@
<p class="lead-text">Send suggestions and keep track of your conversations with the PlotDirector team.</p> <p class="lead-text">Send suggestions and keep track of your conversations with the PlotDirector team.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<a class="btn btn-outline-primary" asp-action="Roadmap">View Roadmap</a>
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a> <a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
</div> </div>
</div> </div>
@ -54,7 +55,13 @@ else
@foreach (var request in Model.Requests) @foreach (var request in Model.Requests)
{ {
<tr> <tr>
<td>@request.Title</td> <td>
@if (request.UserNeedsAttention)
{
<span class="badge text-bg-warning me-1">New</span>
}
@request.Title
</td>
<td>@StatusBadge(request.Status)</td> <td>@StatusBadge(request.Status)</td>
<td>@FormatNullableDate(request.LatestActivityUtc)</td> <td>@FormatNullableDate(request.LatestActivityUtc)</td>
<td>@ConversationBadge(request.ConversationState)</td> <td>@ConversationBadge(request.ConversationState)</td>

View File

@ -0,0 +1,93 @@
@model FeatureRequestRoadmapViewModel
@using Microsoft.AspNetCore.Html
@using System.Text.Encodings.Web
@{
ViewData["Title"] = "Feature Roadmap";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Feedback</p>
<h1>Feature Roadmap</h1>
<p class="lead-text">Here are some improvements currently being considered, planned, in progress, or recently shipped. This is not a complete list of every suggestion, but it shows where PlotDirector is actively evolving.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index">My Requests</a>
<a class="btn btn-primary" asp-action="Create">Submit Feature Request</a>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-action="Index">Feature Requests</a>
<span class="muted"> / Roadmap</span>
</nav>
@foreach (var roadmapSection in Model.Sections)
{
<section class="edit-panel">
<h2>@roadmapSection.Heading</h2>
@if (!roadmapSection.Requests.Any())
{
<p class="muted mb-0">@EmptyMessage(roadmapSection.Status)</p>
}
else
{
<div class="d-flex flex-column gap-3">
@foreach (var request in roadmapSection.Requests)
{
<article class="border rounded p-3">
<div class="d-flex justify-content-between gap-3 flex-wrap mb-2">
<div>
<h3 class="fs-5 mb-1">@request.Title</h3>
<div class="d-flex flex-wrap gap-2">
@StatusBadge(request.Status)
@if (!string.IsNullOrWhiteSpace(request.AppArea))
{
<span class="badge text-bg-secondary">@request.AppArea</span>
}
</div>
</div>
@if (string.Equals(request.Status, FeatureRequestStatuses.Shipped, StringComparison.OrdinalIgnoreCase) && request.ShippedUtc.HasValue)
{
<span class="muted">Shipped @FormatDate(request.ShippedUtc.Value)</span>
}
</div>
<p class="mb-0">@Excerpt(request.Description)</p>
</article>
}
</div>
}
</section>
}
@functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
private static string Excerpt(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 220 ? cleaned : $"{cleaned[..220]}...";
}
private static string EmptyMessage(string status) => status switch
{
FeatureRequestStatuses.Shipped => "No shipped roadmap items yet.",
FeatureRequestStatuses.InProgress => "No roadmap items are currently in progress.",
FeatureRequestStatuses.Planned => "No planned roadmap items yet.",
_ => "No roadmap items in this section yet."
};
private static IHtmlContent StatusBadge(string status)
{
var css = status switch
{
FeatureRequestStatuses.Candidate => "text-bg-secondary",
FeatureRequestStatuses.Planned => "text-bg-success",
FeatureRequestStatuses.InProgress => "text-bg-success",
FeatureRequestStatuses.Shipped => "text-bg-dark",
_ => "text-bg-secondary"
};
return new HtmlString($"<span class=\"badge {css}\">{HtmlEncoder.Default.Encode(status)}</span>");
}
}

View File

@ -2,13 +2,18 @@
<html lang="en" data-theme="light" data-bs-theme="light"> <html lang="en" data-theme="light" data-bs-theme="light">
@using System.Security.Claims @using System.Security.Claims
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject IFeatureRequestService FeatureRequestService
@{ @{
var shellClass = ViewData["ShellClass"] as string ?? string.Empty; var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase); var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
var userEmail = User.FindFirstValue(ClaimTypes.Email); var userEmail = User.FindFirstValue(ClaimTypes.Email);
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier);
var userId = int.TryParse(userIdValue, out var parsedUserId) ? parsedUserId : (int?)null;
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? []; var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var isAdmin = !string.IsNullOrWhiteSpace(userEmail) var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
var defaultTitle = $"{ViewData["Title"]} - PlotDirector"; var defaultTitle = $"{ViewData["Title"]} - PlotDirector";
var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle; var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null; var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
@ -128,12 +133,24 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a> <a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">Feature Requests</a> <a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
Feature Requests
@if (featureRequestAttentionCount > 0)
{
<span class="badge text-bg-warning ms-1">@featureRequestAttentionCount</span>
}
</a>
</li> </li>
@if (isAdmin) @if (isAdmin)
{ {
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">Admin</a> <a class="nav-link text-dark" asp-area="" asp-controller="Admin" asp-action="Index">
Admin
@if (adminFeatureRequestAttentionCount > 0)
{
<span class="badge text-bg-danger ms-1">@adminFeatureRequestAttentionCount</span>
}
</a>
</li> </li>
} }
} }

View File

@ -169,6 +169,41 @@ td.text-end {
max-width: 980px; max-width: 980px;
} }
.feature-request-admin-page .page-heading.compact,
.feature-request-admin-page .edit-panel {
max-width: none;
}
.feature-request-admin-table {
table-layout: fixed;
}
.feature-request-admin-table th,
.feature-request-admin-table td {
vertical-align: middle;
}
.feature-request-admin-table__preview {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.feature-request-admin-table__action {
width: 78px;
}
@media (max-width: 1100px) {
.feature-request-admin-table {
table-layout: auto;
}
.feature-request-admin-table__preview {
max-width: 160px;
}
}
.trial-status-panel, .trial-status-panel,
.trial-dashboard-banner { .trial-dashboard-banner {
border: 1px solid var(--plotline-line); border: 1px solid var(--plotline-line);

File diff suppressed because one or more lines are too long