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
+46
View File
@@ -16,6 +16,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
public DbSet<ShowactApplication> ShowactApplications => Set<ShowactApplication>();
public DbSet<Sponsor> Sponsors => Set<Sponsor>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
@@ -40,6 +42,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
entity.Property(item => item.ContactUrl).HasMaxLength(400);
entity.Property(item => item.SponsorsUrl).HasMaxLength(400);
entity.Property(item => item.ShowactsUrl).HasMaxLength(400);
entity.Property(item => item.ShowactsContent).HasDefaultValue(string.Empty);
entity.Property(item => item.DemoLoginEmail).HasMaxLength(180);
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
@@ -51,6 +55,14 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.TwitchScope).HasMaxLength(300);
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
});
modelBuilder.Entity<TeamMember>(entity =>
@@ -88,6 +100,12 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
entity.Property(item => item.ClipCompilationTitle).HasMaxLength(200);
entity.Property(item => item.ClipCompilationPlatform).HasMaxLength(40);
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
});
modelBuilder.Entity<Nomination>(entity =>
@@ -169,6 +187,34 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ShowactApplication>(entity =>
{
entity.Property(item => item.ArtistName).HasMaxLength(120);
entity.Property(item => item.ContactEmail).HasMaxLength(180);
entity.Property(item => item.ContactDiscord).HasMaxLength(120);
entity.Property(item => item.PlatformUrl).HasMaxLength(500);
entity.Property(item => item.PerformanceType).HasMaxLength(80);
entity.Property(item => item.Description).HasMaxLength(1000);
entity.Property(item => item.TechnicalNotes).HasMaxLength(1000);
entity.Property(item => item.ReferenceUrl).HasMaxLength(500);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
entity.HasIndex(item => new { item.SeasonId, item.Status });
});
modelBuilder.Entity<Sponsor>(entity =>
{
entity.Property(item => item.Name).HasMaxLength(120);
entity.Property(item => item.WebsiteUrl).HasMaxLength(500);
entity.Property(item => item.LogoUrl).HasMaxLength(500);
entity.Property(item => item.Description).HasMaxLength(500);
entity.Property(item => item.Tier).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.IsVisible, item.SortOrder });
});
SeedData.Apply(modelBuilder);
}
}
@@ -16,6 +16,30 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
@@ -40,6 +64,12 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsUrl" character varying(400) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL,
@@ -121,6 +151,74 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
CREATE TABLE IF NOT EXISTS "ShowactApplications" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"ArtistName" character varying(120) NOT NULL,
"ContactEmail" character varying(180) NOT NULL,
"ContactDiscord" character varying(120) NOT NULL,
"PlatformUrl" character varying(500) NOT NULL,
"PerformanceType" character varying(80) NOT NULL,
"Description" character varying(1000) NOT NULL,
"TechnicalNotes" character varying(1000) NOT NULL,
"ReferenceUrl" character varying(500) NOT NULL,
"Status" character varying(20) NOT NULL,
"ReviewNote" character varying(500) NULL,
"ReviewedByTwitchId" character varying(120) NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"UserAgent" character varying(400) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_ShowactApplications_SeasonId_Status"
ON "ShowactApplications" ("SeasonId", "Status");
CREATE TABLE IF NOT EXISTS "Sponsors" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"Name" character varying(120) NOT NULL,
"WebsiteUrl" character varying(500) NOT NULL,
"LogoUrl" character varying(500) NOT NULL,
"Description" character varying(500) NOT NULL,
"Tier" character varying(80) NOT NULL,
"SortOrder" integer NOT NULL,
"IsVisible" boolean NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_Sponsors_SeasonId_IsVisible_SortOrder"
ON "Sponsors" ("SeasonId", "IsVisible", "SortOrder");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ShowactApplications_Seasons_SeasonId'
) THEN
ALTER TABLE "ShowactApplications"
ADD CONSTRAINT "FK_ShowactApplications_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
ON DELETE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_Sponsors_Seasons_SeasonId'
) THEN
ALTER TABLE "Sponsors"
ADD CONSTRAINT "FK_Sponsors_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
ON DELETE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
@@ -138,6 +236,24 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "AcceptanceStatus" character varying(30) NOT NULL DEFAULT 'open';
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "AcceptanceNote" character varying(500) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationUrl" character varying(500) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationTitle" character varying(200) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationPlatform" character varying(40) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
+10
View File
@@ -48,7 +48,17 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
ContactContent = SeedCatalog.DefaultContactContent,
SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
ClipSubmissionsEnabled = false,
ClipReviewEnabled = true,
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
ShowactApplicationsEnabled = false,
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
SponsorsVisible = true,
SocialLinksJson = JsonSerializer.Serialize(new[]
{
new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true },
@@ -41,6 +41,26 @@ public static partial class SeedDataBootstrapper
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
}
if (!HasValidWorkflowRules(settings.WorkflowRulesJson))
{
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
}
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
{
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
}
if (string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage))
{
settings.ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
}
if (string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage))
{
settings.ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.";
}
if (string.IsNullOrWhiteSpace(settings.ImprintContent))
{
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
@@ -55,6 +75,16 @@ public static partial class SeedDataBootstrapper
{
settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent;
}
if (string.IsNullOrWhiteSpace(settings.ShowactsUrl))
{
settings.ShowactsUrl = "https://vtuber-star-awards.de/showacts";
}
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
{
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.";
}
}
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
@@ -87,4 +117,30 @@ public static partial class SeedDataBootstrapper
private static bool HasValidRiskRules(string? json) =>
RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length;
private static bool HasValidWorkflowRules(string? json) =>
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
private static bool HasValidNominationLinkBlacklist(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
using var document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Array
&& document.RootElement.EnumerateArray().Any(item =>
item.ValueKind == JsonValueKind.Object
&& item.TryGetProperty("Url", out var url)
&& url.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(url.GetString()));
}
catch (JsonException)
{
return false;
}
}
}