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:
@@ -9,6 +9,9 @@ namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
private static readonly string[] CandidateAcceptanceStatuses = ["open", "contacted", "accepted", "declined"];
|
||||
private static readonly string[] CandidateClipEmbedStatuses = ["unchecked", "embeddable", "link_only", "blocked"];
|
||||
|
||||
private static async Task<IResult> CreateCandidate(
|
||||
HttpContext context,
|
||||
int seasonId,
|
||||
@@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||
|
||||
if (normalizedClipUrl is null)
|
||||
{
|
||||
normalizedClipTitle = null;
|
||||
normalizedClipPlatform = null;
|
||||
normalizedClipEmbedStatus = "unchecked";
|
||||
}
|
||||
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == seasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
@@ -40,6 +57,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
seasonId,
|
||||
request.CategoryId,
|
||||
null,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
var candidate = new Candidate
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
@@ -47,6 +78,12 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
DisplayName = normalizedDisplayName,
|
||||
ChannelSlug = normalizedChannelSlug,
|
||||
Platform = request.Platform.Trim(),
|
||||
AcceptanceStatus = normalizedAcceptanceStatus,
|
||||
AcceptanceNote = normalizedAcceptanceNote,
|
||||
ClipCompilationUrl = normalizedClipUrl,
|
||||
ClipCompilationTitle = normalizedClipTitle,
|
||||
ClipCompilationPlatform = normalizedClipPlatform,
|
||||
ClipEmbedStatus = normalizedClipEmbedStatus,
|
||||
};
|
||||
|
||||
db.Candidates.Add(candidate);
|
||||
@@ -56,7 +93,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
"candidate",
|
||||
request.DisplayName.Trim(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.CategoryId, request.Platform },
|
||||
new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -92,6 +129,20 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var normalizedDisplayName = request.DisplayName.Trim();
|
||||
var normalizedChannelSlug = request.ChannelSlug.Trim();
|
||||
var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses);
|
||||
var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote);
|
||||
var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl);
|
||||
var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle);
|
||||
var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform);
|
||||
var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses);
|
||||
|
||||
if (normalizedClipUrl is null)
|
||||
{
|
||||
normalizedClipTitle = null;
|
||||
normalizedClipPlatform = null;
|
||||
normalizedClipEmbedStatus = "unchecked";
|
||||
}
|
||||
|
||||
if (await db.Candidates.AnyAsync(item =>
|
||||
item.SeasonId == candidate.SeasonId
|
||||
&& item.CategoryId == request.CategoryId
|
||||
@@ -102,10 +153,30 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." });
|
||||
}
|
||||
|
||||
var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync(
|
||||
db,
|
||||
candidate.SeasonId,
|
||||
request.CategoryId,
|
||||
candidateId,
|
||||
normalizedDisplayName,
|
||||
normalizedChannelSlug,
|
||||
normalizedAcceptanceStatus,
|
||||
context.RequestAborted);
|
||||
if (workflowRuleBlock is not null)
|
||||
{
|
||||
return workflowRuleBlock;
|
||||
}
|
||||
|
||||
candidate.CategoryId = request.CategoryId;
|
||||
candidate.DisplayName = normalizedDisplayName;
|
||||
candidate.ChannelSlug = normalizedChannelSlug;
|
||||
candidate.Platform = request.Platform.Trim();
|
||||
candidate.AcceptanceStatus = normalizedAcceptanceStatus;
|
||||
candidate.AcceptanceNote = normalizedAcceptanceNote;
|
||||
candidate.ClipCompilationUrl = normalizedClipUrl;
|
||||
candidate.ClipCompilationTitle = normalizedClipTitle;
|
||||
candidate.ClipCompilationPlatform = normalizedClipPlatform;
|
||||
candidate.ClipEmbedStatus = normalizedClipEmbedStatus;
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -113,7 +184,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||
new { request.CategoryId, request.Platform },
|
||||
new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -146,4 +217,34 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { deleted = true, candidateId });
|
||||
}
|
||||
|
||||
private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection<string> allowedValues)
|
||||
{
|
||||
var normalized = value?.Trim().ToLowerInvariant();
|
||||
return !string.IsNullOrWhiteSpace(normalized) && allowedValues.Contains(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalCandidateText(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalCandidateUrl(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new BadHttpRequestException("Compilation-Link muss eine gültige http(s)-URL sein.");
|
||||
}
|
||||
|
||||
return uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user