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
@@ -0,0 +1,121 @@
using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record NominationLinkBlacklistEntry(string Url);
public static class NominationLinkBlacklistSettings
{
public static readonly NominationLinkBlacklistEntry[] Defaults =
[
new("https://www.twitch.tv/"),
new("https://kick.com/"),
new("https://www.youtube.com/"),
];
public static NominationLinkBlacklistEntry[] Read(SiteSettings? settings)
{
var entries = Parse(settings?.NominationLinkBlacklistJson);
return entries.Length > 0 ? entries : Defaults;
}
public static string Serialize(IEnumerable<NominationLinkBlacklistEntry> entries)
{
var normalizedEntries = entries
.Select(entry => NormalizeEntry(entry.Url))
.Where(entry => entry is not null)
.Select(entry => new NominationLinkBlacklistEntry(entry!))
.DistinctBy(entry => BuildComparisonKey(entry.Url), StringComparer.OrdinalIgnoreCase)
.OrderBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase)
.ToArray();
return JsonSerializer.Serialize(normalizedEntries);
}
public static bool TryNormalizeUrl(string? rawUrl, out string normalizedUrl)
{
normalizedUrl = NormalizeEntry(rawUrl) ?? string.Empty;
return !string.IsNullOrWhiteSpace(normalizedUrl);
}
public static bool IsBlocked(string rawUrl, IEnumerable<NominationLinkBlacklistEntry> entries)
{
var submittedKey = BuildComparisonKey(rawUrl);
return !string.IsNullOrWhiteSpace(submittedKey)
&& entries.Any(entry => string.Equals(BuildComparisonKey(entry.Url), submittedKey, StringComparison.OrdinalIgnoreCase));
}
private static NominationLinkBlacklistEntry[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
var entries = JsonSerializer.Deserialize<NominationLinkBlacklistEntry[]>(json);
return entries?.Where(entry => !string.IsNullOrWhiteSpace(entry.Url)).ToArray() ?? [];
}
catch (JsonException)
{
return [];
}
}
private static string? NormalizeEntry(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!candidate.Contains("://", StringComparison.Ordinal))
{
candidate = $"https://{candidate}";
}
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host))
{
return null;
}
var builder = new UriBuilder(uri)
{
Scheme = Uri.UriSchemeHttps,
Host = uri.Host.ToLowerInvariant(),
Port = -1,
Query = string.Empty,
Fragment = string.Empty,
};
var normalized = builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped);
return normalized.EndsWith('/') ? normalized : $"{normalized}/";
}
private static string BuildComparisonKey(string? rawUrl)
{
if (NormalizeEntry(rawUrl) is not { } normalized)
{
return string.Empty;
}
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri))
{
return string.Empty;
}
var host = uri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)
? uri.Host[4..]
: uri.Host;
var path = uri.AbsolutePath.TrimEnd('/');
return $"{host.ToLowerInvariant()}{path.ToLowerInvariant()}";
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record WorkflowRuleSetting(
string Key,
string Label,
bool Enabled,
int Limit,
string Mode,
string Description);
public static class WorkflowRuleSettings
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public const string MaxFinalistsPerCategory = "max_finalists_per_category";
public const string MaxCandidateAppearances = "max_candidate_appearances";
public const string MaxWinnerPlacements = "max_winner_placements";
public const string WinnerRequiresClip = "winner_requires_clip";
public static WorkflowRuleSetting[] Defaults { get; } =
[
new(MaxFinalistsPerCategory, "Finale Kandidat:innen pro Kategorie", true, 4, "block", "Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden."),
new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."),
new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."),
new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."),
];
public static WorkflowRuleSetting[] Read(SiteSettings? settings)
{
var storedRules = Parse(settings?.WorkflowRulesJson);
return Defaults
.Select(defaultRule =>
{
var storedRule = storedRules.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
return storedRule is null ? defaultRule : Normalize(storedRule, defaultRule);
})
.ToArray();
}
public static string Serialize(IEnumerable<WorkflowRuleSetting> rules) =>
JsonSerializer.Serialize(rules.Select(rule => Normalize(rule, Defaults.FirstOrDefault(item => item.Key == rule.Key) ?? rule)), JsonOptions);
public static WorkflowRuleSetting Find(IEnumerable<WorkflowRuleSetting> rules, string key) =>
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
?? Defaults.First(item => item.Key == key);
public static bool ShouldBlock(WorkflowRuleSetting rule) =>
rule.Enabled && string.Equals(rule.Mode, "block", StringComparison.OrdinalIgnoreCase);
public static string CandidateIdentityKey(Candidate candidate)
{
var channel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(channel))
{
return $"slug:{channel}";
}
return $"name:{candidate.DisplayName.Trim().ToLowerInvariant()}";
}
public static string CandidateIdentityKey(string displayName, string channelSlug)
{
var channel = channelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(channel))
{
return $"slug:{channel}";
}
return $"name:{displayName.Trim().ToLowerInvariant()}";
}
private static WorkflowRuleSetting[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<WorkflowRuleSetting[]>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static WorkflowRuleSetting Normalize(WorkflowRuleSetting rule, WorkflowRuleSetting fallback)
{
var mode = rule.Mode.Trim().ToLowerInvariant() switch
{
"block" => "block",
"warn" => "warn",
_ => fallback.Mode,
};
return rule with
{
Key = fallback.Key,
Label = string.IsNullOrWhiteSpace(rule.Label) ? fallback.Label : rule.Label.Trim(),
Enabled = rule.Enabled,
Limit = Math.Clamp(rule.Limit, 1, 50),
Mode = mode,
Description = string.IsNullOrWhiteSpace(rule.Description) ? fallback.Description : rule.Description.Trim(),
};
}
}