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,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()}";
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user