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:
AzuTear
2026-06-27 18:39:35 +02:00
parent 494eba5edd
commit 18b61bed52
119 changed files with 15638 additions and 367 deletions
@@ -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);
}
}