Improve admin candidate modal UX and add clip menu visibility toggle
- Widen AdminCandidateEditorModal to size lg for better readability - Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link" - Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport - Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
using Backend.Common;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static class AdminExtrasEndpoints
|
||||
{
|
||||
private static readonly string[] ContentPermissions = ["content", "settings"];
|
||||
private static readonly HashSet<string> AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"pending",
|
||||
"shortlisted",
|
||||
"accepted",
|
||||
"rejected",
|
||||
};
|
||||
|
||||
public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
|
||||
.WithName("GetAdminShowactApplications")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("UpdateAdminShowactStatus")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("DeleteAdminShowactApplication")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
|
||||
.WithName("GetAdminSponsors")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("CreateAdminSponsor")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("UpdateAdminSponsor")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
|
||||
.WithName("DeleteAdminSponsor")
|
||||
.WithOpenApi();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetShowactApplications(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var applications = await db.ShowactApplications
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.Status == "pending" ? 0 : 1)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Select(item => ToDto(item))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(applications);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateShowactStatus(
|
||||
HttpContext context,
|
||||
int applicationId,
|
||||
UpdateShowactStatusRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
|
||||
if (application is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var status = NormalizeStatus(request.Status);
|
||||
if (!AllowedShowactStatuses.Contains(status))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." });
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
var before = new { application.Status, application.ReviewNote };
|
||||
application.Status = status;
|
||||
application.ReviewNote = NormalizeText(request.ReviewNote, 500);
|
||||
application.ReviewedByTwitchId = session.TwitchUserId;
|
||||
application.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"showact.status",
|
||||
"showact-application",
|
||||
application.Id.ToString(),
|
||||
$"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.",
|
||||
new { before, after = new { application.Status, application.ReviewNote } },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, application = ToDto(application) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteShowactApplication(
|
||||
HttpContext context,
|
||||
int applicationId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
|
||||
if (application is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
db.ShowactApplications.Remove(application);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"showact.delete",
|
||||
"showact-application",
|
||||
application.Id.ToString(),
|
||||
$"Showact-Bewerbung von {application.ArtistName} wurde geloescht.",
|
||||
new { application.ArtistName, application.ContactEmail, application.Status },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, applicationId });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSponsors(int seasonId, AwardsDbContext db)
|
||||
{
|
||||
var sponsors = await db.Sponsors
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Select(item => ToDto(item))
|
||||
.ToArrayAsync();
|
||||
|
||||
return Results.Ok(sponsors);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSponsor(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
UpsertSponsorRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow };
|
||||
var validation = ApplySponsorRequest(sponsor, request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
db.Sponsors.Add(sponsor);
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"sponsor.create",
|
||||
"sponsor",
|
||||
"new",
|
||||
$"Sponsor {sponsor.Name} wurde angelegt.",
|
||||
ToDto(sponsor),
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSponsor(
|
||||
HttpContext context,
|
||||
int sponsorId,
|
||||
UpsertSponsorRequest request,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
|
||||
if (sponsor is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = ToDto(sponsor);
|
||||
var validation = ApplySponsorRequest(sponsor, request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"sponsor.update",
|
||||
"sponsor",
|
||||
sponsor.Id.ToString(),
|
||||
$"Sponsor {sponsor.Name} wurde aktualisiert.",
|
||||
new { before, after = ToDto(sponsor) },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteSponsor(
|
||||
HttpContext context,
|
||||
int sponsorId,
|
||||
AwardsDbContext db,
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
|
||||
if (sponsor is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
db.Sponsors.Remove(sponsor);
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
"sponsor.delete",
|
||||
"sponsor",
|
||||
sponsor.Id.ToString(),
|
||||
$"Sponsor {sponsor.Name} wurde geloescht.",
|
||||
ToDto(sponsor),
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, sponsorId });
|
||||
}
|
||||
|
||||
private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request)
|
||||
{
|
||||
var name = NormalizeText(request.Name, 120);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." });
|
||||
}
|
||||
|
||||
var websiteUrl = NormalizeText(request.WebsiteUrl, 500);
|
||||
var logoUrl = NormalizeText(request.LogoUrl, 500);
|
||||
if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." });
|
||||
}
|
||||
|
||||
sponsor.Name = name;
|
||||
sponsor.WebsiteUrl = websiteUrl;
|
||||
sponsor.LogoUrl = logoUrl;
|
||||
sponsor.Description = NormalizeText(request.Description, 500);
|
||||
sponsor.Tier = NormalizeText(request.Tier, 80);
|
||||
if (string.IsNullOrWhiteSpace(sponsor.Tier))
|
||||
{
|
||||
sponsor.Tier = "Partner";
|
||||
}
|
||||
|
||||
sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999);
|
||||
sponsor.IsVisible = request.IsVisible;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SponsorDto ToDto(Sponsor sponsor) =>
|
||||
new(
|
||||
sponsor.Id,
|
||||
sponsor.SeasonId,
|
||||
sponsor.Name,
|
||||
sponsor.WebsiteUrl,
|
||||
sponsor.LogoUrl,
|
||||
sponsor.Description,
|
||||
sponsor.Tier,
|
||||
sponsor.SortOrder,
|
||||
sponsor.IsVisible);
|
||||
|
||||
private static ShowactApplicationDto ToDto(ShowactApplication application) =>
|
||||
new(
|
||||
application.Id,
|
||||
application.SeasonId,
|
||||
application.ArtistName,
|
||||
application.ContactEmail,
|
||||
application.ContactDiscord,
|
||||
application.PlatformUrl,
|
||||
application.PerformanceType,
|
||||
application.Description,
|
||||
application.TechnicalNotes,
|
||||
application.ReferenceUrl,
|
||||
application.Status,
|
||||
application.ReviewNote,
|
||||
application.CreatedAt,
|
||||
application.ReviewedAt);
|
||||
|
||||
private static string NormalizeStatus(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeText(string? value, int maxLength)
|
||||
{
|
||||
var trimmed = (value ?? string.Empty).Trim();
|
||||
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static bool IsBlankOrHttpUrl(string value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
|
||||
}
|
||||
Reference in New Issue
Block a user