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
+1
View File
@@ -184,5 +184,6 @@ public static class SeasonMappings
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
new FooterLinkDto("sponsors", "Sponsoren & Partner", settings.SponsorsUrl, settings.SponsorsContent),
new FooterLinkDto("showacts", "Showacts", settings.ShowactsUrl, settings.ShowactsContent),
];
}
@@ -91,6 +91,14 @@ public sealed record ApproveNominationRequest(
public sealed record RejectNominationRequest(string? ReviewNote);
public sealed record AdminNominationLinkBlacklistEntryDto(string Url);
public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries);
public sealed record UpdateNominationLinkBlacklistRequest(string[] Urls);
public sealed record AddNominationLinkBlacklistEntryRequest(string Url);
public sealed record UpdateClipStatusRequest(
string Status,
string? ReviewNote);
@@ -116,3 +124,15 @@ public sealed record AdminRiskRuleDto(
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules);
public sealed record AdminWorkflowRuleDto(
string Key,
string Label,
bool Enabled,
int Limit,
string Mode,
string Description);
public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules);
public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules);
+14 -2
View File
@@ -23,7 +23,13 @@ public sealed record AdminCandidateItemDto(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
string Platform,
string AcceptanceStatus,
string? AcceptanceNote,
string? ClipCompilationUrl,
string? ClipCompilationTitle,
string? ClipCompilationPlatform,
string ClipEmbedStatus);
public sealed record AdminAwardResultItemDto(
int Id,
@@ -102,7 +108,13 @@ public sealed record UpsertCandidateRequest(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
string Platform,
string? AcceptanceStatus = null,
string? AcceptanceNote = null,
string? ClipCompilationUrl = null,
string? ClipCompilationTitle = null,
string? ClipCompilationPlatform = null,
string? ClipEmbedStatus = null);
public sealed record SetAwardResultRequest(
int CategoryId,
@@ -14,6 +14,8 @@ public sealed record AdminSiteSettingsResponse(
string ContactContent,
string SponsorsUrl,
string SponsorsContent,
string ShowactsUrl,
string ShowactsContent,
IEnumerable<PublicSocialLinkDto> SocialLinks,
IEnumerable<FaqItemDto> Faq);
@@ -29,6 +31,8 @@ public sealed record UpdateSiteSettingsRequest(
string ContactContent,
string SponsorsUrl,
string SponsorsContent,
string ShowactsUrl,
string ShowactsContent,
PublicSocialLinkDto[] SocialLinks,
FaqItemDto[] Faq);
@@ -49,6 +53,24 @@ public sealed record AdminOperationalSettingsResponse(
string MaintenanceTitle,
string MaintenanceMessage);
public sealed record AdminOptionalFeatureSettingsResponse(
bool ClipSubmissionsEnabled,
bool ClipReviewEnabled,
bool ClipAdminMenuVisible,
string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled,
string ShowactApplicationDisabledMessage,
bool SponsorsVisible);
public sealed record UpdateOptionalFeatureSettingsRequest(
bool ClipSubmissionsEnabled,
bool ClipReviewEnabled,
bool ClipAdminMenuVisible,
string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled,
string ShowactApplicationDisabledMessage,
bool SponsorsVisible);
public sealed record UpdateOperationalSettingsRequest(
bool DemoLoginEnabled,
string DemoLoginEmail,
+51
View File
@@ -0,0 +1,51 @@
namespace Backend.Contracts;
public sealed record SponsorDto(
int Id,
int SeasonId,
string Name,
string WebsiteUrl,
string LogoUrl,
string Description,
string Tier,
int SortOrder,
bool IsVisible);
public sealed record PublicSponsorsResponse(int Year, SponsorDto[] Items);
public sealed record UpsertSponsorRequest(
string Name,
string WebsiteUrl,
string LogoUrl,
string Description,
string Tier,
int SortOrder,
bool IsVisible);
public sealed record ShowactApplicationDto(
int Id,
int SeasonId,
string ArtistName,
string ContactEmail,
string ContactDiscord,
string PlatformUrl,
string PerformanceType,
string Description,
string TechnicalNotes,
string ReferenceUrl,
string Status,
string? ReviewNote,
DateTimeOffset CreatedAt,
DateTimeOffset? ReviewedAt);
public sealed record CreateShowactApplicationRequest(
string ArtistName,
string ContactEmail,
string ContactDiscord,
string PlatformUrl,
string PerformanceType,
string Description,
string TechnicalNotes,
string ReferenceUrl);
public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote);
+14 -1
View File
@@ -20,7 +20,11 @@ public sealed record WinnerPreviewDto(
string WinnerName,
string WinnerSlug,
string WinnerPlatform,
string WinnerUrl);
string WinnerUrl,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform,
string? ClipEmbedStatus);
public sealed record ArchiveYearDto(
int Year,
@@ -57,6 +61,14 @@ public sealed record PublicSiteStatusResponse(
string MaintenanceTitle,
string MaintenanceMessage);
public sealed record PublicFeatureFlagsDto(
bool ClipSubmissionsEnabled,
bool ClipReviewEnabled,
string ClipSubmissionDisabledMessage,
bool ShowactApplicationsEnabled,
string ShowactApplicationDisabledMessage,
bool SponsorsVisible);
public sealed record OverviewResponse(
int SeasonId,
int Year,
@@ -72,4 +84,5 @@ public sealed record OverviewResponse(
IEnumerable<WinnerPreviewDto> WinnersPreview,
IEnumerable<ArchiveYearDto> ArchiveYears,
PublicSiteContentDto SiteContent,
PublicFeatureFlagsDto FeatureFlags,
IEnumerable<FaqItemDto> Faq);
@@ -4,10 +4,12 @@ public sealed record CandidateSummaryDto(
int Id,
string DisplayName,
string ChannelSlug,
string ChannelUrl,
string Platform,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform);
string? ClipPlatform,
string? ClipEmbedStatus);
public sealed record PublicCategoryDetailDto(
int Id,
@@ -5,7 +5,11 @@ public sealed record WinnerArchiveItemDto(
string WinnerName,
string WinnerSlug,
string WinnerPlatform,
string WinnerUrl);
string WinnerUrl,
string? ClipUrl,
string? ClipTitle,
string? ClipPlatform,
string? ClipEmbedStatus);
public sealed record WinnerArchiveResponse(
int Year,
+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;
}
}
}
+6
View File
@@ -10,4 +10,10 @@ public sealed class Candidate
public string DisplayName { get; set; } = string.Empty;
public string ChannelSlug { get; set; } = string.Empty;
public string Platform { get; set; } = "Twitch";
public string AcceptanceStatus { get; set; } = "open";
public string? AcceptanceNote { get; set; }
public string? ClipCompilationUrl { get; set; }
public string? ClipCompilationTitle { get; set; }
public string? ClipCompilationPlatform { get; set; }
public string ClipEmbedStatus { get; set; } = "unchecked";
}
+23
View File
@@ -0,0 +1,23 @@
namespace Backend.Domain;
public sealed class ShowactApplication
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public string ArtistName { get; set; } = string.Empty;
public string ContactEmail { get; set; } = string.Empty;
public string ContactDiscord { get; set; } = string.Empty;
public string PlatformUrl { get; set; } = string.Empty;
public string PerformanceType { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string TechnicalNotes { get; set; } = string.Empty;
public string ReferenceUrl { get; set; } = string.Empty;
public string Status { get; set; } = "pending";
public string? ReviewNote { get; set; }
public string? ReviewedByTwitchId { get; set; }
public string CreatedFromIp { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ReviewedAt { get; set; }
}
+11
View File
@@ -16,9 +16,20 @@ public sealed class SiteSettings
public string ContactContent { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string SponsorsContent { get; set; } = string.Empty;
public string ShowactsUrl { get; set; } = string.Empty;
public string ShowactsContent { get; set; } = string.Empty;
public string SocialLinksJson { get; set; } = "[]";
public string FaqJson { get; set; } = "[]";
public string RiskRulesJson { get; set; } = "[]";
public string WorkflowRulesJson { get; set; } = "[]";
public string NominationLinkBlacklistJson { get; set; } = "[]";
public bool ClipSubmissionsEnabled { get; set; }
public bool ClipReviewEnabled { get; set; } = true;
public bool ClipAdminMenuVisible { get; set; } = true;
public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen.";
public bool ShowactApplicationsEnabled { get; set; }
public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen.";
public bool SponsorsVisible { get; set; } = true;
public bool DemoLoginManagedByDatabase { get; set; }
public bool DemoLoginEnabled { get; set; }
public string DemoLoginEmail { get; set; } = string.Empty;
+17
View File
@@ -0,0 +1,17 @@
namespace Backend.Domain;
public sealed class Sponsor
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public string Name { get; set; } = string.Empty;
public string WebsiteUrl { get; set; } = string.Empty;
public string LogoUrl { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Tier { get; set; } = "Partner";
public int SortOrder { get; set; }
public bool IsVisible { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAt { get; set; }
}
+1
View File
@@ -14,6 +14,7 @@ public static class AdminEndpoints
group.MapAdminDashboardEndpoints();
group.MapAdminSeasonManagementEndpoints();
group.MapAdminModerationEndpoints();
group.MapAdminExtrasEndpoints();
group.MapAdminTeamEndpoints();
return app;
+321
View File
@@ -0,0 +1,321 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminExtrasEndpoints
{
private static readonly string[] ContentPermissions = ["content", "settings"];
private static readonly HashSet<string> AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"pending",
"shortlisted",
"accepted",
"rejected",
};
public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
.WithName("GetAdminShowactApplications")
.WithOpenApi();
group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("UpdateAdminShowactStatus")
.WithOpenApi();
group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("DeleteAdminShowactApplication")
.WithOpenApi();
group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions))
.WithName("GetAdminSponsors")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("CreateAdminSponsor")
.WithOpenApi();
group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("UpdateAdminSponsor")
.WithOpenApi();
group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content"))
.WithName("DeleteAdminSponsor")
.WithOpenApi();
return group;
}
private static async Task<IResult> GetShowactApplications(int seasonId, AwardsDbContext db)
{
var applications = await db.ShowactApplications
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.Status == "pending" ? 0 : 1)
.ThenByDescending(item => item.CreatedAt)
.Select(item => ToDto(item))
.ToArrayAsync();
return Results.Ok(applications);
}
private static async Task<IResult> UpdateShowactStatus(
HttpContext context,
int applicationId,
UpdateShowactStatusRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
if (application is null)
{
return Results.NotFound();
}
var status = NormalizeStatus(request.Status);
if (!AllowedShowactStatuses.Contains(status))
{
return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." });
}
var session = AdminEndpointConventions.CurrentSession(context);
var before = new { application.Status, application.ReviewNote };
application.Status = status;
application.ReviewNote = NormalizeText(request.ReviewNote, 500);
application.ReviewedByTwitchId = session.TwitchUserId;
application.ReviewedAt = DateTimeOffset.UtcNow;
adminAuditService.AddEntry(
session.TwitchUserId,
"showact.status",
"showact-application",
application.Id.ToString(),
$"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.",
new { before, after = new { application.Status, application.ReviewNote } },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, application = ToDto(application) });
}
private static async Task<IResult> DeleteShowactApplication(
HttpContext context,
int applicationId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId);
if (application is null)
{
return Results.NotFound();
}
var session = AdminEndpointConventions.CurrentSession(context);
db.ShowactApplications.Remove(application);
adminAuditService.AddEntry(
session.TwitchUserId,
"showact.delete",
"showact-application",
application.Id.ToString(),
$"Showact-Bewerbung von {application.ArtistName} wurde geloescht.",
new { application.ArtistName, application.ContactEmail, application.Status },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, applicationId });
}
private static async Task<IResult> GetSponsors(int seasonId, AwardsDbContext db)
{
var sponsors = await db.Sponsors
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(item => ToDto(item))
.ToArrayAsync();
return Results.Ok(sponsors);
}
private static async Task<IResult> CreateSponsor(
HttpContext context,
int seasonId,
UpsertSponsorRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted))
{
return Results.NotFound();
}
var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow };
var validation = ApplySponsorRequest(sponsor, request);
if (validation is not null)
{
return validation;
}
db.Sponsors.Add(sponsor);
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"sponsor.create",
"sponsor",
"new",
$"Sponsor {sponsor.Name} wurde angelegt.",
ToDto(sponsor),
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
}
private static async Task<IResult> UpdateSponsor(
HttpContext context,
int sponsorId,
UpsertSponsorRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
if (sponsor is null)
{
return Results.NotFound();
}
var before = ToDto(sponsor);
var validation = ApplySponsorRequest(sponsor, request);
if (validation is not null)
{
return validation;
}
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"sponsor.update",
"sponsor",
sponsor.Id.ToString(),
$"Sponsor {sponsor.Name} wurde aktualisiert.",
new { before, after = ToDto(sponsor) },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) });
}
private static async Task<IResult> DeleteSponsor(
HttpContext context,
int sponsorId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted);
if (sponsor is null)
{
return Results.NotFound();
}
var session = AdminEndpointConventions.CurrentSession(context);
db.Sponsors.Remove(sponsor);
adminAuditService.AddEntry(
session.TwitchUserId,
"sponsor.delete",
"sponsor",
sponsor.Id.ToString(),
$"Sponsor {sponsor.Name} wurde geloescht.",
ToDto(sponsor),
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, sponsorId });
}
private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request)
{
var name = NormalizeText(request.Name, 120);
if (string.IsNullOrWhiteSpace(name))
{
return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." });
}
var websiteUrl = NormalizeText(request.WebsiteUrl, 500);
var logoUrl = NormalizeText(request.LogoUrl, 500);
if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl))
{
return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." });
}
sponsor.Name = name;
sponsor.WebsiteUrl = websiteUrl;
sponsor.LogoUrl = logoUrl;
sponsor.Description = NormalizeText(request.Description, 500);
sponsor.Tier = NormalizeText(request.Tier, 80);
if (string.IsNullOrWhiteSpace(sponsor.Tier))
{
sponsor.Tier = "Partner";
}
sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999);
sponsor.IsVisible = request.IsVisible;
return null;
}
private static SponsorDto ToDto(Sponsor sponsor) =>
new(
sponsor.Id,
sponsor.SeasonId,
sponsor.Name,
sponsor.WebsiteUrl,
sponsor.LogoUrl,
sponsor.Description,
sponsor.Tier,
sponsor.SortOrder,
sponsor.IsVisible);
private static ShowactApplicationDto ToDto(ShowactApplication application) =>
new(
application.Id,
application.SeasonId,
application.ArtistName,
application.ContactEmail,
application.ContactDiscord,
application.PlatformUrl,
application.PerformanceType,
application.Description,
application.TechnicalNotes,
application.ReferenceUrl,
application.Status,
application.ReviewNote,
application.CreatedAt,
application.ReviewedAt);
private static string NormalizeStatus(string? value) =>
string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant();
private static string NormalizeText(string? value, int maxLength)
{
var trimmed = (value ?? string.Empty).Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static bool IsBlankOrHttpUrl(string value) =>
string.IsNullOrWhiteSpace(value)
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
}
@@ -20,6 +20,18 @@ public static partial class AdminModerationEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("RejectAdminNomination")
.WithOpenApi();
group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("GetAdminNominationLinkBlacklist")
.WithOpenApi();
group.MapPut("/nominations/link-blacklist", UpdateNominationLinkBlacklist)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("UpdateAdminNominationLinkBlacklist")
.WithOpenApi();
group.MapPost("/nominations/link-blacklist", AddNominationLinkBlacklistEntry)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("AddAdminNominationLinkBlacklistEntry")
.WithOpenApi();
group.MapGet("/risk-flags", GetRiskFlags)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("GetAdminRiskFlags")
@@ -0,0 +1,123 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> GetNominationLinkBlacklist(AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
}
private static async Task<IResult> UpdateNominationLinkBlacklist(
HttpContext context,
UpdateNominationLinkBlacklistRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var normalizedEntries = NormalizeBlacklistEntries(request.Urls, out var invalidUrl);
if (invalidUrl is not null)
{
return Results.BadRequest(new { message = $"Blacklist-Link ist keine gueltige http(s)-URL: {invalidUrl}" });
}
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(normalizedEntries);
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination-link-blacklist.update",
"site-settings",
settings.Id.ToString(),
"Nominierungs-Link-Blacklist wurde aktualisiert.",
new { count = normalizedEntries.Length },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
}
private static async Task<IResult> AddNominationLinkBlacklistEntry(
HttpContext context,
AddNominationLinkBlacklistEntryRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(request.Url, out var normalizedUrl))
{
return Results.BadRequest(new { message = "Blacklist-Link ist keine gueltige http(s)-URL." });
}
var entries = NominationLinkBlacklistSettings.Read(settings).ToList();
if (!NominationLinkBlacklistSettings.IsBlocked(normalizedUrl, entries))
{
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(entries);
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination-link-blacklist.add",
"site-settings",
settings.Id.ToString(),
"Link wurde zur Nominierungs-Blacklist hinzugefuegt.",
new { url = normalizedUrl },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(ToNominationLinkBlacklistResponse(settings));
}
private static AdminNominationLinkBlacklistResponse ToNominationLinkBlacklistResponse(Backend.Domain.SiteSettings settings) =>
new(NominationLinkBlacklistSettings.Read(settings)
.Select(entry => new AdminNominationLinkBlacklistEntryDto(entry.Url))
.ToArray());
private static NominationLinkBlacklistEntry[] NormalizeBlacklistEntries(string[]? urls, out string? invalidUrl)
{
invalidUrl = null;
var entries = new List<NominationLinkBlacklistEntry>();
foreach (var rawUrl in urls ?? [])
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
continue;
}
if (!NominationLinkBlacklistSettings.TryNormalizeUrl(rawUrl, out var normalizedUrl))
{
invalidUrl = rawUrl;
return [];
}
entries.Add(new NominationLinkBlacklistEntry(normalizedUrl));
}
return entries
.DistinctBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
@@ -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);
}
}
@@ -26,7 +26,13 @@ public static partial class AdminSeasonManagementEndpoints
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.Platform))
item.Platform,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
item.ClipCompilationTitle,
item.ClipCompilationPlatform,
item.ClipEmbedStatus))
.ToArrayAsync();
var candidateCounts = candidates
@@ -56,6 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("DeleteAdminResult")
.WithOpenApi();
group.MapGet("/workflow-rules", GetWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("GetAdminWorkflowRules")
.WithOpenApi();
group.MapPut("/workflow-rules", UpdateWorkflowRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings))
.WithName("UpdateAdminWorkflowRules")
.WithOpenApi();
return group;
}
}
@@ -1,6 +1,8 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
@@ -14,6 +16,17 @@ public static partial class AdminSeasonManagementEndpoints
private const int MaxCandidateDisplayNameLength = 120;
private const int MaxCandidateChannelSlugLength = 120;
private const int MaxCandidatePlatformLength = 60;
private const int MaxCandidateAcceptanceNoteLength = 500;
private const int MaxCandidateClipUrlLength = 500;
private const int MaxCandidateClipTitleLength = 200;
private const int MaxCandidateClipPlatformLength = 40;
private sealed record CandidateRuleSnapshot(
int Id,
int CategoryId,
string DisplayName,
string ChannelSlug,
string AcceptanceStatus);
private static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
{
@@ -132,6 +145,80 @@ public static partial class AdminSeasonManagementEndpoints
});
}
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
return WorkflowRuleSettings.Read(settings);
}
private static IResult CreateWorkflowRuleError(string message) =>
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
private static async Task<IResult?> BuildCandidateWorkflowRuleBlockAsync(
AwardsDbContext db,
int seasonId,
int categoryId,
int? existingCandidateId,
string displayName,
string channelSlug,
string acceptanceStatus,
CancellationToken cancellationToken)
{
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
return null;
}
var existingCandidates = await db.Candidates
.AsNoTracking()
.Where(item =>
item.SeasonId == seasonId
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
&& item.AcceptanceStatus != "declined")
.Select(item => new CandidateRuleSnapshot(
item.Id,
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.AcceptanceStatus))
.ToArrayAsync(cancellationToken);
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
{
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
if (categoryCount >= finalistsRule.Limit)
{
return CreateWorkflowRuleError(
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
}
}
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
var appearanceCount = existingCandidates.Count(item =>
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
if (appearanceCount >= appearancesRule.Limit)
{
return CreateWorkflowRuleError(
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
}
}
return null;
}
private static string[] BuildNewSeasonReadinessIssues(
string currentPhase,
bool isCurrent,
@@ -311,6 +398,51 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." });
}
if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses))
{
return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." });
}
if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses))
{
return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." });
}
if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength)
{
return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." });
}
var clipUrl = request.ClipCompilationUrl?.Trim();
if (!string.IsNullOrWhiteSpace(clipUrl))
{
if (clipUrl.Length > MaxCandidateClipUrlLength)
{
return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." });
}
if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." });
}
}
if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength)
{
return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." });
}
if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength)
{
return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." });
}
return null;
}
private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection<string> allowedValues)
{
var normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
}
@@ -36,6 +36,40 @@ public static partial class AdminSeasonManagementEndpoints
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)
&& string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl))
{
return CreateWorkflowRuleError(
"Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen.");
}
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule))
{
var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate);
var existingWinnerIdentities = await db.Results
.AsNoTracking()
.Include(item => item.Candidate)
.Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId)
.Select(item => new
{
item.CategoryId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
})
.ToArrayAsync(context.RequestAborted);
var existingWinnerCount = existingWinnerIdentities.Count(item =>
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal));
if (existingWinnerCount >= winnerPlacementsRule.Limit)
{
return CreateWorkflowRuleError(
$"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}.");
}
}
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
item.SeasonId == seasonId
&& item.CategoryId == request.CategoryId);
+118 -1
View File
@@ -13,6 +13,7 @@ public static class AdminSiteSettingsEndpoints
{
private const string FallbackMaintenanceTitle = "Sternenpause";
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
@@ -32,6 +33,14 @@ public static class AdminSiteSettingsEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOperationalSettings")
.WithOpenApi();
group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminOptionalFeatureSettings")
.WithOpenApi();
group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOptionalFeatureSettings")
.WithOpenApi();
return group;
}
@@ -57,6 +66,8 @@ public static class AdminSiteSettingsEndpoints
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings)));
}
@@ -100,6 +111,8 @@ public static class AdminSiteSettingsEndpoints
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
settings.ShowactsContent = request.ShowactsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
@@ -133,7 +146,8 @@ public static class AdminSiteSettingsEndpoints
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
@@ -144,6 +158,7 @@ public static class AdminSiteSettingsEndpoints
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
ShowactsUrl = showactsUrl,
};
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
@@ -187,6 +202,108 @@ public static class AdminSiteSettingsEndpoints
public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string ShowactsUrl { get; set; } = string.Empty;
}
private static async Task<IResult> GetOptionalFeatureSettings(AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
}
private static async Task<IResult> UpdateOptionalFeatureSettings(
HttpContext context,
UpdateOptionalFeatureSettingsRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var before = ToOptionalFeatureSettingsResponse(settings);
var disabledMessage = NormalizeOptionalFeatureText(
request.ClipSubmissionDisabledMessage,
FallbackClipSubmissionDisabledMessage,
240);
var showactDisabledMessage = NormalizeOptionalFeatureText(
request.ShowactApplicationDisabledMessage,
"Showact-Bewerbungen sind aktuell geschlossen.",
240);
settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled;
settings.ClipReviewEnabled = request.ClipReviewEnabled;
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
settings.ClipSubmissionDisabledMessage = disabledMessage;
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
settings.SponsorsVisible = request.SponsorsVisible;
var after = ToOptionalFeatureSettingsResponse(settings);
adminAuditService.AddEntry(
session.TwitchUserId,
"optional-features.update",
"site-settings",
settings.Id.ToString(),
"Optionale Workflow-Features wurden aktualisiert.",
new
{
before,
after,
changes = BuildOptionalFeatureChanges(before, after),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(after);
}
private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) =>
new(
settings.ClipSubmissionsEnabled,
settings.ClipReviewEnabled,
settings.ClipAdminMenuVisible,
string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage)
? FallbackClipSubmissionDisabledMessage
: settings.ClipSubmissionDisabledMessage,
settings.ShowactApplicationsEnabled,
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: settings.ShowactApplicationDisabledMessage,
settings.SponsorsVisible);
private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength)
{
var trimmed = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return fallback;
}
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static object[] BuildOptionalFeatureChanges(
AdminOptionalFeatureSettingsResponse before,
AdminOptionalFeatureSettingsResponse after)
{
var changes = new List<object>();
AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled);
AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled);
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
return changes.ToArray();
}
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
@@ -0,0 +1,87 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> GetWorkflowRules(AwardsDbContext db)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray()));
}
private static async Task<IResult> UpdateWorkflowRules(
HttpContext context,
UpdateWorkflowRulesRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
var before = WorkflowRuleSettings.Read(settings);
var mergedRules = WorkflowRuleSettings.Defaults
.Select(defaultRule =>
{
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
return requestRule is null
? defaultRule
: new WorkflowRuleSetting(
defaultRule.Key,
defaultRule.Label,
requestRule.Enabled,
requestRule.Limit,
requestRule.Mode,
defaultRule.Description);
})
.ToArray();
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules);
var after = WorkflowRuleSettings.Read(settings);
var changes = after
.Select(rule =>
{
var previous = before.First(item => item.Key == rule.Key);
return new
{
field = rule.Key,
label = rule.Label,
from = $"{previous.Enabled}/{previous.Limit}/{previous.Mode}",
to = $"{rule.Enabled}/{rule.Limit}/{rule.Mode}",
sensitive = false,
};
})
.Where(change => change.from != change.to)
.ToArray();
adminAuditService.AddEntry(
session.TwitchUserId,
"workflow-rules.update",
"site-settings",
settings.Id.ToString(),
"Workflow-Regeln wurden aktualisiert.",
new { changes },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new AdminWorkflowRulesResponse(after.Select(ToWorkflowRuleDto).ToArray()));
}
private static AdminWorkflowRuleDto ToWorkflowRuleDto(WorkflowRuleSetting rule) =>
new(rule.Key, rule.Label, rule.Enabled, rule.Limit, rule.Mode, rule.Description);
}
+16
View File
@@ -28,6 +28,22 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var siteSettings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
if (siteSettings is null)
{
return Results.Problem("Site settings are missing.");
}
if (!siteSettings.ClipSubmissionsEnabled)
{
var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
? "Clip-Einreichungen sind aktuell geschlossen."
: siteSettings.ClipSubmissionDisabledMessage;
return Results.BadRequest(new { message });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (clipSeasonResolution.Result is not null)
+9
View File
@@ -22,6 +22,10 @@ public static partial class PublicEndpoints
.WithName("GetWinnerArchive")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
.WithName("GetPublicSponsors")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
.WithName("GetUserParticipation")
.WithOpenApi();
@@ -41,6 +45,11 @@ public static partial class PublicEndpoints
.WithName("CreateClip")
.WithOpenApi();
group.MapPost("/showacts", CreateShowactApplication)
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
.WithName("CreateShowactApplication")
.WithOpenApi();
return app;
}
}
+131
View File
@@ -0,0 +1,131 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetSponsors(int year, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Year == year);
if (season is null)
{
return Results.NotFound();
}
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null || !settings.SponsorsVisible)
{
return Results.Ok(new PublicSponsorsResponse(year, []));
}
var sponsors = await db.Sponsors
.AsNoTracking()
.Where(item => item.SeasonId == season.Id && item.IsVisible)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(item => new SponsorDto(
item.Id,
item.SeasonId,
item.Name,
item.WebsiteUrl,
item.LogoUrl,
item.Description,
item.Tier,
item.SortOrder,
item.IsVisible))
.ToArrayAsync();
return Results.Ok(new PublicSponsorsResponse(year, sponsors));
}
private static async Task<IResult> CreateShowactApplication(
HttpContext context,
CreateShowactApplicationRequest request,
AwardsDbContext db)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
if (settings is null || !settings.ShowactApplicationsEnabled)
{
return Results.BadRequest(new
{
message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: settings.ShowactApplicationDisabledMessage,
});
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted);
if (season is null)
{
return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." });
}
var artistName = NormalizePublicText(request.ArtistName, 120);
var contactEmail = NormalizePublicText(request.ContactEmail, 180);
var contactDiscord = NormalizePublicText(request.ContactDiscord, 120);
var performanceType = NormalizePublicText(request.PerformanceType, 80);
var description = NormalizePublicText(request.Description, 1000);
var platformUrl = NormalizePublicText(request.PlatformUrl, 500);
var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500);
if (string.IsNullOrWhiteSpace(artistName))
{
return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." });
}
if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord))
{
return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." });
}
if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description))
{
return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." });
}
if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl))
{
return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." });
}
var metadata = RequestMetadataReader.Read(context);
var application = new ShowactApplication
{
SeasonId = season.Id,
ArtistName = artistName,
ContactEmail = contactEmail,
ContactDiscord = contactDiscord,
PlatformUrl = platformUrl,
PerformanceType = performanceType,
Description = description,
TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000),
ReferenceUrl = referenceUrl,
Status = "pending",
CreatedFromIp = metadata.ClientIp,
UserAgent = metadata.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ShowactApplications.Add(application);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, applicationId = application.Id });
}
private static string NormalizePublicText(string? value, int maxLength)
{
var trimmed = (value ?? string.Empty).Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private static bool IsBlankOrHttpUrl(string value) =>
string.IsNullOrWhiteSpace(value)
|| (Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps));
}
@@ -54,6 +54,17 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
}
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
var blacklistedStreamUrl = submittedNominations
.Select(item => item.StreamUrl)
.FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist));
if (blacklistedStreamUrl is not null)
{
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
}
var category = await db.Categories
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
+20 -1
View File
@@ -42,6 +42,10 @@ public static partial class PublicEndpoints
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
WinnerPlatform = result.Candidate.Platform,
ClipUrl = result.Candidate.ClipCompilationUrl,
ClipTitle = result.Candidate.ClipCompilationTitle,
ClipPlatform = result.Candidate.ClipCompilationPlatform,
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
})
.ToArrayAsync();
@@ -52,7 +56,11 @@ public static partial class PublicEndpoints
result.WinnerName,
result.WinnerSlug,
result.WinnerPlatform,
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
result.ClipUrl,
result.ClipTitle,
result.ClipPlatform,
result.ClipEmbedStatus))
.ToArray();
var archiveYearRows = await db.Results
@@ -110,6 +118,17 @@ public static partial class PublicEndpoints
siteSettings.PrivacyPolicyContent,
SeasonMappings.ReadSocialLinks(siteSettings),
SeasonMappings.BuildFooterLinks(siteSettings)),
new PublicFeatureFlagsDto(
siteSettings.ClipSubmissionsEnabled,
siteSettings.ClipReviewEnabled,
string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
? "Clip-Einreichungen sind aktuell geschlossen."
: siteSettings.ClipSubmissionDisabledMessage,
siteSettings.ShowactApplicationsEnabled,
string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage)
? "Showact-Bewerbungen sind aktuell geschlossen."
: siteSettings.ShowactApplicationDisabledMessage,
siteSettings.SponsorsVisible),
SeasonMappings.ReadFaqItems(siteSettings));
return Results.Ok(response);
@@ -54,17 +54,36 @@ public static partial class PublicEndpoints
category.GroupName,
category.Description,
category.MaxNomineesPerUser,
category.Candidates.Select(candidate =>
category.Candidates
.Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
.Select(candidate =>
{
var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey);
var candidateClipUrl = string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)
|| string.Equals(candidate.ClipEmbedStatus, "blocked", StringComparison.OrdinalIgnoreCase)
? null
: candidate.ClipCompilationUrl.Trim();
var clipUrl = candidateClipUrl ?? clip?.ClipUrl;
var clipTitle = candidateClipUrl is not null
? string.IsNullOrWhiteSpace(candidate.ClipCompilationTitle) ? "Highlight-Clip ansehen" : candidate.ClipCompilationTitle.Trim()
: clip?.Title;
var clipPlatform = candidateClipUrl is not null
? string.IsNullOrWhiteSpace(candidate.ClipCompilationPlatform) ? candidate.Platform : candidate.ClipCompilationPlatform.Trim()
: clip?.Platform;
var clipEmbedStatus = candidateClipUrl is not null
? candidate.ClipEmbedStatus
: null;
return new CandidateSummaryDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
candidate.Platform,
clip?.ClipUrl,
clip?.Title,
clip?.Platform);
clipUrl,
clipTitle,
clipPlatform,
clipEmbedStatus);
}).ToArray()))
.ToArray()));
}
@@ -35,6 +35,10 @@ public static partial class PublicEndpoints
WinnerName = result.Candidate.DisplayName,
WinnerSlug = result.Candidate.ChannelSlug,
WinnerPlatform = result.Candidate.Platform,
ClipUrl = result.Candidate.ClipCompilationUrl,
ClipTitle = result.Candidate.ClipCompilationTitle,
ClipPlatform = result.Candidate.ClipCompilationPlatform,
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
})
.ToArrayAsync();
@@ -44,7 +48,11 @@ public static partial class PublicEndpoints
result.WinnerName,
result.WinnerSlug,
result.WinnerPlatform,
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
result.ClipUrl,
result.ClipTitle,
result.ClipPlatform,
result.ClipEmbedStatus))
.ToArray();
return Results.Ok(new WinnerArchiveResponse(year, items));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddCandidatePreparationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AcceptanceNote",
table: "Candidates",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AcceptanceStatus",
table: "Candidates",
type: "character varying(30)",
maxLength: 30,
nullable: false,
defaultValue: "open");
migrationBuilder.AddColumn<string>(
name: "ClipCompilationPlatform",
table: "Candidates",
type: "character varying(40)",
maxLength: 40,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClipCompilationTitle",
table: "Candidates",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClipCompilationUrl",
table: "Candidates",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClipEmbedStatus",
table: "Candidates",
type: "character varying(30)",
maxLength: 30,
nullable: false,
defaultValue: "unchecked");
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 3,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 4,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 5,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 6,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 7,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 8,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 9,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 10,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 11,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 12,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
migrationBuilder.UpdateData(
table: "Candidates",
keyColumn: "Id",
keyValue: 13,
columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" },
values: new object[] { null, "open", null, null, null, "unchecked" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AcceptanceNote",
table: "Candidates");
migrationBuilder.DropColumn(
name: "AcceptanceStatus",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipCompilationPlatform",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipCompilationTitle",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipCompilationUrl",
table: "Candidates");
migrationBuilder.DropColumn(
name: "ClipEmbedStatus",
table: "Candidates");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddWorkflowRulesJson : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
""");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
column: "WorkflowRulesJson",
value: "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"}]");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "WorkflowRulesJson";
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddOptionalClipFeatureSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true;
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.';
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false;
""");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ClipReviewEnabled", "ClipSubmissionDisabledMessage" },
values: new object[] { true, "Clip-Einreichungen sind aktuell geschlossen." });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "ClipReviewEnabled";
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "ClipSubmissionDisabledMessage";
""");
migrationBuilder.Sql("""
ALTER TABLE "SiteSettings"
DROP COLUMN IF EXISTS "ClipSubmissionsEnabled";
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddNominationLinkBlacklist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NominationLinkBlacklistJson",
table: "SiteSettings",
type: "text",
nullable: false,
defaultValue: "[]");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
column: "NominationLinkBlacklistJson",
value: "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NominationLinkBlacklistJson",
table: "SiteSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddClipAdminMenuVisible : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ClipAdminMenuVisible",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ShowactApplicationDisabledMessage",
table: "SiteSettings",
type: "character varying(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "ShowactApplicationsEnabled",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ShowactsContent",
table: "SiteSettings",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ShowactsUrl",
table: "SiteSettings",
type: "character varying(400)",
maxLength: 400,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "SponsorsVisible",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.CreateTable(
name: "ShowactApplications",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
ArtistName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
ContactEmail = table.Column<string>(type: "character varying(180)", maxLength: 180, nullable: false),
ContactDiscord = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
PlatformUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
PerformanceType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
TechnicalNotes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
ReferenceUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ReviewNote = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
ReviewedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
CreatedFromIp = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
UserAgent = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShowactApplications", x => x.Id);
table.ForeignKey(
name: "FK_ShowactApplications_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Sponsors",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
WebsiteUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Tier = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsVisible = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Sponsors", x => x.Id);
table.ForeignKey(
name: "FK_Sponsors_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "ClipAdminMenuVisible", "ShowactApplicationDisabledMessage", "ShowactsContent", "ShowactsUrl", "SponsorsVisible", "WorkflowRulesJson" },
values: new object[] { true, "Showact-Bewerbungen sind aktuell geschlossen.", "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", "https://vtuber-star-awards.de/showacts", true, "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" });
migrationBuilder.CreateIndex(
name: "IX_ShowactApplications_SeasonId_Status",
table: "ShowactApplications",
columns: new[] { "SeasonId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Sponsors_SeasonId_IsVisible_SortOrder",
table: "Sponsors",
columns: new[] { "SeasonId", "IsVisible", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ShowactApplications");
migrationBuilder.DropTable(
name: "Sponsors");
migrationBuilder.DropColumn(
name: "ClipAdminMenuVisible",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactApplicationDisabledMessage",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactApplicationsEnabled",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactsContent",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "ShowactsUrl",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "SponsorsVisible",
table: "SiteSettings");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
column: "WorkflowRulesJson",
value: "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"}]");
}
}
}
@@ -169,6 +169,17 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AcceptanceNote")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AcceptanceStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasDefaultValue("open");
b.Property<int>("CategoryId")
.HasColumnType("integer");
@@ -177,6 +188,25 @@ namespace Backend.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("ClipCompilationPlatform")
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("ClipCompilationTitle")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClipCompilationUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ClipEmbedStatus")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasDefaultValue("unchecked");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
@@ -202,8 +232,10 @@ namespace Backend.Migrations
new
{
Id = 1,
AcceptanceStatus = "open",
CategoryId = 1,
ChannelSlug = "@hoshimimiyu",
ClipEmbedStatus = "unchecked",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 1
@@ -211,8 +243,10 @@ namespace Backend.Migrations
new
{
Id = 2,
AcceptanceStatus = "open",
CategoryId = 1,
ChannelSlug = "@kurainu",
ClipEmbedStatus = "unchecked",
DisplayName = "Kurainu",
Platform = "Twitch",
SeasonId = 1
@@ -220,8 +254,10 @@ namespace Backend.Migrations
new
{
Id = 3,
AcceptanceStatus = "open",
CategoryId = 1,
ChannelSlug = "@shiroch",
ClipEmbedStatus = "unchecked",
DisplayName = "Shiro Ch.",
Platform = "Twitch",
SeasonId = 1
@@ -229,8 +265,10 @@ namespace Backend.Migrations
new
{
Id = 4,
AcceptanceStatus = "open",
CategoryId = 2,
ChannelSlug = "@kurainu",
ClipEmbedStatus = "unchecked",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 1
@@ -238,8 +276,10 @@ namespace Backend.Migrations
new
{
Id = 5,
AcceptanceStatus = "open",
CategoryId = 2,
ChannelSlug = "@aoisakura",
ClipEmbedStatus = "unchecked",
DisplayName = "Aoi Sakura Showcase",
Platform = "YouTube",
SeasonId = 1
@@ -247,8 +287,10 @@ namespace Backend.Migrations
new
{
Id = 6,
AcceptanceStatus = "open",
CategoryId = 3,
ChannelSlug = "@pyonkichikingdom",
ClipEmbedStatus = "unchecked",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 1
@@ -256,8 +298,10 @@ namespace Backend.Migrations
new
{
Id = 7,
AcceptanceStatus = "open",
CategoryId = 4,
ChannelSlug = "@moonrelay",
ClipEmbedStatus = "unchecked",
DisplayName = "Moonrelay",
Platform = "Twitch",
SeasonId = 1
@@ -265,8 +309,10 @@ namespace Backend.Migrations
new
{
Id = 8,
AcceptanceStatus = "open",
CategoryId = 5,
ChannelSlug = "@hoshimimiyu",
ClipEmbedStatus = "unchecked",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 2
@@ -274,8 +320,10 @@ namespace Backend.Migrations
new
{
Id = 9,
AcceptanceStatus = "open",
CategoryId = 6,
ChannelSlug = "@kurainu",
ClipEmbedStatus = "unchecked",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 2
@@ -283,8 +331,10 @@ namespace Backend.Migrations
new
{
Id = 10,
AcceptanceStatus = "open",
CategoryId = 7,
ChannelSlug = "@pyonkichikingdom",
ClipEmbedStatus = "unchecked",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 2
@@ -292,8 +342,10 @@ namespace Backend.Migrations
new
{
Id = 11,
AcceptanceStatus = "open",
CategoryId = 8,
ChannelSlug = "@aoisakura",
ClipEmbedStatus = "unchecked",
DisplayName = "Aoi Sakura",
Platform = "YouTube",
SeasonId = 3
@@ -301,8 +353,10 @@ namespace Backend.Migrations
new
{
Id = 12,
AcceptanceStatus = "open",
CategoryId = 9,
ChannelSlug = "@starbyte",
ClipEmbedStatus = "unchecked",
DisplayName = "Starbyte",
Platform = "Twitch",
SeasonId = 3
@@ -310,8 +364,10 @@ namespace Backend.Migrations
new
{
Id = 13,
AcceptanceStatus = "open",
CategoryId = 10,
ChannelSlug = "@tenshivox",
ClipEmbedStatus = "unchecked",
DisplayName = "Tenshi Vox",
Platform = "Twitch",
SeasonId = 4
@@ -844,6 +900,93 @@ namespace Backend.Migrations
});
});
modelBuilder.Entity("Backend.Domain.ShowactApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ArtistName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("ContactDiscord")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("character varying(180)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedFromIp")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("PerformanceType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("PlatformUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReferenceUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReviewNote")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset?>("ReviewedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReviewedByTwitchId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TechnicalNotes")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("UserAgent")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.HasKey("Id");
b.HasIndex("SeasonId", "Status");
b.ToTable("ShowactApplications");
});
modelBuilder.Entity("Backend.Domain.SiteSettings", b =>
{
b.Property<int>("Id")
@@ -852,6 +995,24 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ClipAdminMenuVisible")
.HasColumnType("boolean");
b.Property<bool>("ClipReviewEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("ClipSubmissionDisabledMessage")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<bool>("ClipSubmissionsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ContactContent")
.IsRequired()
.HasColumnType("text");
@@ -933,6 +1094,12 @@ namespace Backend.Migrations
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("NominationLinkBlacklistJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("[]");
b.Property<string>("PrivacyEmail")
.IsRequired()
.HasMaxLength(160)
@@ -953,6 +1120,27 @@ namespace Backend.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("ShowactApplicationDisabledMessage")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<bool>("ShowactApplicationsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ShowactsContent")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("");
b.Property<string>("ShowactsUrl")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("SocialLinksJson")
.IsRequired()
.HasColumnType("text");
@@ -966,6 +1154,11 @@ namespace Backend.Migrations
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<bool>("SponsorsVisible")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("TwitchAuthManagedByDatabase")
.HasColumnType("boolean");
@@ -989,6 +1182,12 @@ namespace Backend.Migrations
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("WorkflowRulesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("[]");
b.HasKey("Id");
b.ToTable("SiteSettings");
@@ -997,6 +1196,10 @@ namespace Backend.Migrations
new
{
Id = 1,
ClipAdminMenuVisible = true,
ClipReviewEnabled = true,
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
ClipSubmissionsEnabled = false,
ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.",
ContactUrl = "https://vtuber-star-awards.de/kontakt",
DemoLoginDisplayName = "Jayuhime Admin",
@@ -1015,22 +1218,84 @@ namespace Backend.Migrations
MaintenanceModeEnabled = false,
MaintenanceTitle = "Sternenpause",
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]",
PrivacyEmail = "datenschutz@vtuber-star-awards.de",
PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.",
PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
PrivacyPolicyUpdatedBy = "seed",
RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]",
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
ShowactApplicationsEnabled = false,
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]",
SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.",
SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsVisible = true,
TwitchAuthManagedByDatabase = false,
TwitchClientId = "",
TwitchClientSecret = "",
TwitchRedirectUri = "",
TwitchScope = ""
TwitchScope = "",
WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]"
});
});
modelBuilder.Entity("Backend.Domain.Sponsor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsVisible")
.HasColumnType("boolean");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Tier")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("SeasonId", "IsVisible", "SortOrder");
b.ToTable("Sponsors");
});
modelBuilder.Entity("Backend.Domain.TeamMember", b =>
{
b.Property<int>("Id")
@@ -1408,6 +1673,28 @@ namespace Backend.Migrations
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.ShowactApplication", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Sponsor", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
@@ -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(),
};
}
}
+255
View File
@@ -0,0 +1,255 @@
# Workflow-Feedback: Planung und Ausarbeitung
Stand: 2026-06-27
Quelle: `VTuber Star Awards Workflow.docx` inklusive Kommentaren, abgeglichen mit dem aktuellen VTubeAwards-Repo.
## Executive Summary
Die erste grosse Aenderung sollte den Core Workflow stabilisieren: Nominierung, Vorbereitung, Voting und Review/Auswertung. Das Feedback beschreibt weniger ein komplett neues Produktmodell als eine klarere Fuehrung durch bereits vorhandene Konzepte.
Die wichtigsten Produktentscheidungen:
- Kategorien mit Viewer-Groessen werden als einzelne Kategorien pro Unterkategorie abgebildet, gruppiert ueber `GroupName`.
- Viewer duerfen pro Kategorie bis zu drei Stream- oder Kanal-Links nominieren.
- Eine Nominierung muss nicht fuer alle Kategorien abgegeben werden.
- Clip-Compilations werden nicht als Videodateien in der App gespeichert.
- Voting und Gewinnerbereiche nutzen externe YouTube-/Twitch-Links oder Embeds.
- Die Vorbereitung nach der Nominierung bleibt ein Admin-/Teamprozess mit manuellem Kontakt und Annahmestatus.
- Showacts und Sponsoren werden als admin-verwaltbare Folgefeatures geplant, aber nicht in Phase 1 umgesetzt.
## Feedback-Analyse
### Kategorien und Unterkategorien
Das Dokument beschreibt pro Award-Kategorie drei Unterkategorien nach Viewer-Groesse:
- Hidden Star: 1 bis 20 Viewer
- Rising Star: 21 bis 60 Viewer
- Shining Star: 61+ Viewer
Im aktuellen Datenmodell passt das am besten zu einzelnen `Category`-Datensaetzen pro Unterkategorie. Der uebergeordnete Award-Bereich, zum Beispiel `Gamer`, bleibt `GroupName`; die konkrete Unterkategorie wird `Name`, zum Beispiel `Hidden Star der Gamer`.
Damit entsteht kein paralleles Kategorienmodell. Admin-, Public- und Voting-Flows koennen weiter mit `CategoryId` arbeiten.
### Nominierung
Das Feedback wuenscht pro Kategorie bis zu drei Nominierungen als Stream-/Kanal-Links. Namen sind nicht zwingend noetig, weil die Admins aus dem Link den finalen Kandidaten erstellen oder zuordnen koennen.
Geplanter Zielzustand:
- Pro Kategorie koennen ein bis drei Links eingereicht werden.
- Doppelte Links innerhalb derselben Kategorie werden blockiert.
- Leere Kategorien duerfen uebersprungen werden.
- Die Nominierungsoberflaeche wird wie ein Wizard aufgebaut: Kategorien links, Inhalt rechts, klare Weiter-Navigation.
- Clip-Einreichung wird aus dem Nominierungsformular entfernt oder deutlich getrennt, weil laut Feedback Clips in der Nominierungsphase eher Probleme verursachen.
Backendseitig existiert bereits eine passende Grundlage: `CreateNominationRequest` unterstuetzt mehrere `Nominations`, und die API verhindert doppelte Links innerhalb eines Requests. Die groesste Arbeit liegt daher im Public UI und in der Kommunikation der Regeln.
### Vorbereitung
Nach der Nominierungsphase prueft das Team die Nominierungen, zaehlt aus und kontaktiert VTuber, ob sie die Nominierung annehmen. Erst danach werden Clip-Compilations relevant.
Geplanter Zielzustand:
- Admins sehen pro Review-Fall genug Signal, um Kandidaten zuzuordnen.
- Final ausgewaehlte Nominierte bekommen einen Annahmestatus.
- Admins koennen pro Kandidat eine externe Clip-Compilation-URL pflegen.
- Es wird keine Upload-Infrastruktur fuer Videodateien gebaut.
- Ungelistete YouTube-Videos oder Twitch-Clips koennen verlinkt oder eingebettet werden.
Das haelt die Verantwortung fuer Hosting, Speicher, Copyright und Transcoding ausserhalb der App.
### Voting
Das Feedback zum Voting betrifft vor allem Bedienung und Sicherheit vor unvollstaendigen Abgaben.
Geplanter Zielzustand:
- Das Voting bleibt ein gefuehrter Picker mit Kategorienavigation.
- Es gibt einen klaren Weiter-Button unten rechts.
- Das Modal behaelt eine stabile Groesse, damit beim Kategorienwechsel nichts springt.
- Vor dem Absenden wird angezeigt, in welchen Kategorien noch keine Stimme gesetzt wurde.
- Nutzer koennen Votes bearbeiten, solange die Votingphase aktiv ist.
Backendseitig ist das Bearbeiten bereits angelegt: ein bestehendes Ballot wird beim erneuten Speichern ersetzt. Das sollte im UI bewusst als Feature kommuniziert werden.
### Review und Auswertung
Das Dokument nennt zwei interne Regeln:
- Eine Person kann maximal zwei Mal nominiert werden.
- Eine Person kann maximal ein Mal gewinnen.
Diese Regeln sollten nicht still im Public UI verschwinden, sondern als Admin-Guard und Review-Hilfe geplant werden.
Geplanter Zielzustand:
- Admins koennen final maximal vier Nominierte pro Unterkategorie festlegen.
- Admins sehen Warnungen, wenn eine Person zu oft nominiert oder als Gewinner markiert wird.
- Die App blockiert riskante finale Veroeffentlichungen oder verlangt eine bewusste Admin-Bestaetigung.
- Gewinner werden pro Unterkategorie bestimmt; bei Gleichstand oder Sonderfaellen entscheidet das Team manuell.
### Website Extras
Showacts und Sponsoren sind sinnvoll, aber nicht Teil der ersten Core-Workflow-Aenderung.
Folgeplanung:
- Showact-Bewerbungen werden als eigenes Website-Formular geplant, mit Admin-Liste zur Sichtung.
- Sponsoren werden admin-verwaltbar, inklusive Logo, Link, Sichtbarkeit, Sortierung und optionaler Tier-Stufe.
- Sponsorendarstellung kann als Landingpage-Banner, Karussell oder dedizierter Abschnitt umgesetzt werden.
## Priorisierte Umsetzung
### Phase 1: Public Nominierungs- und Voting-UX
Ziel: Der Public Flow entspricht dem Feedback, ohne zuerst das Datenmodell stark umzubauen.
Umsetzung:
- Nominierungsmodal zu einem Wizard umbauen.
- Pro Kategorie bis zu drei Link-Felder anbieten.
- Kategorien als linke Navigation anzeigen.
- Kategorien ohne Eingaben erlauben.
- Doppelte Links clientseitig validieren und Backend-Fehler sauber anzeigen.
- Clip-Einreichung aus dem Nominierungsflow entfernen oder als separaten, weniger prominenten Flow belassen.
- Voting-Wizard um Weiter-Button und fehlende-Stimmen-Hinweis erweitern.
- Vote-Bearbeitung sichtbar kommunizieren, wenn bereits gespeicherte Stimmen geladen wurden.
Akzeptanz:
- Eine Kategorie kann mit einem, zwei oder drei Links eingereicht werden.
- Doppelte Links in derselben Kategorie werden blockiert.
- Ein leerer Kategorienblock verhindert nicht das Absenden anderer Kategorien.
- Voting kann gespeichert und in derselben Phase erneut geaendert werden.
### Phase 2: Admin-Vorbereitung und Clip-Compilation-Links
Ziel: Das Team kann aus Review-Signalen finale Nominierte vorbereiten und externe Compilations pflegen.
Umsetzung:
- Admin-Review um einen klaren Schritt "finale Nominierte auswaehlen" erweitern.
- Annahmestatus je finalem Nominee planen: offen, angefragt, angenommen, abgesagt.
- Externe Clip-Compilation-URL, Titel und Plattform je Kandidat oder Kandidaten-Kategorie-Zuordnung pflegen.
- Keine Videodateien speichern.
- Optional Embed-Vorschau fuer YouTube/Twitch anzeigen, wenn technisch sicher moeglich.
Akzeptanz:
- Admins koennen sehen, welche Kandidaten fuer eine Unterkategorie final vorbereitet sind.
- Externe Clip-Links erscheinen im Voting.
- Fehlende Clips blockieren die App nicht, werden aber sichtbar markiert.
### Phase 3: Review, Gewinner und Archiv
Ziel: Auswertung und Gewinnerdarstellung folgen den internen Regeln.
Umsetzung:
- Gewinner pro Unterkategorie verwalten.
- Guard fuer "eine Person gewinnt maximal ein Mal" einplanen.
- Konfigurierbare Regel "Gewinner braucht Clip-Link" einplanen; Standard blockiert Gewinner ohne gepflegte YouTube-/Twitch-Compilation.
- Guard oder Warnung fuer "eine Person maximal zwei Mal nominiert" einplanen.
- Gewinnerbilder im Archiv groesser und ruhiger darstellen.
- Clip-Compilation im Archiv anzeigen; Standard ist externer Link oder Embed, kein Upload.
- Countdown auf der Landingpage visuell groesser und prominenter gestalten.
Akzeptanz:
- Admins koennen Gewinner nicht versehentlich doppelt vergeben, ohne Warnung oder bewusste Bestaetigung.
- Admins koennen steuern, ob Gewinner ohne Clip-Link blockiert oder nur gewarnt werden.
- Archiv zeigt vorhandene Gewinner-Clips eingebettet an und bleibt bei fehlenden Clips stabil.
- Countdown ist auf Desktop und Mobile gut lesbar.
### Phase 4: Showacts und Sponsoren
Ziel: Website Extras werden admin-verwaltbar statt ueber externe Workarounds gepflegt.
Umsetzung:
- Showact-Bewerbungsformular als Public Feature umsetzen; Aktivierung laeuft ueber optionale Workflow-Einstellungen.
- Admin-Ansicht fuer Showact-Bewerbungen umsetzen, inklusive Status `pending`, `shortlisted`, `accepted`, `rejected` und Review-Notiz.
- Sponsorendatenmodell umsetzen: Name, Logo, URL, Tier, Sortierung, Sichtbarkeit, Saison.
- Sponsorverwaltung im Admin-Panel umsetzen und Sponsorendarstellung auf der Landingpage an `SponsorsVisible` koppeln.
- Public-Showact-Submit blockiert sauber, wenn Bewerbungen geschlossen sind, und nutzt den konfigurierbaren Admin-Hinweis.
Akzeptanz:
- Showact-Bewerbungen koennen ohne Google Form gesammelt werden.
- Sponsoren koennen ohne Codeaenderung pro Saison gepflegt und sortiert werden.
- Toggles fuer Showact-Bewerbungen und Sponsoren-Sichtbarkeit liegen im eigenen Modal fuer optionale Workflows.
## Interface- und Datenentscheidungen
### Beibehalten
- `CategoryId` bleibt die zentrale Einheit fuer Nominierung und Voting.
- `GroupName` bleibt die Gruppierung fuer uebergeordnete Award-Bereiche.
- Die Nominierungs-API bleibt grundsaetzlich erhalten.
- Die Voting-API bleibt grundsaetzlich erhalten.
- Wiederholtes Vote-Speichern bleibt erlaubt und wird als Bearbeiten behandelt.
### Erweitern
- Kandidaten oder Kandidaten-Kategorie-Zuordnungen brauchen externe Clip-Compilation-Metadaten, falls die vorhandene Clip-Zuordnung nicht ausreicht:
- URL
- Titel
- Plattform
- optionaler Embed-Status
- Admin-Review braucht Statusinformationen fuer final ausgewaehlte Nominierte.
- Gewinnerverwaltung braucht Guards fuer interne Regeln, inklusive "maximal ein Gewinnerplatz" und "Clip-Link fuer Gewinner erforderlich".
### Nicht bauen
- Kein eigener Video-Upload.
- Kein Speichern von Videodateien.
- Kein paralleles Unterkategorienmodell neben `Category`.
- Kein verpflichtendes Komplett-Ausfuellen aller Kategorien.
## Offene Produktfragen
Diese Fragen muessen nicht vor Phase 1 beantwortet werden, sollten aber vor Phase 2 oder 3 geklaert sein:
- Wird der Annahmestatus pro Kandidat global oder pro Kategorie gepflegt?
- Soll eine abgesagte Person automatisch durch die naechste Review-Auswahl ersetzt werden koennen?
- Soll die "maximal zwei Nominierungen"-Regel hart blockieren oder nur warnen?
- Soll die "maximal ein Gewinner"-Regel hart blockieren oder Admin-Override erlauben?
- Werden Sponsor-Tiers oeffentlich benannt oder nur intern zur Sortierung genutzt?
- Welche Pflichtfelder braucht das Showact-Formular final?
## Test Plan
### Build und statische Checks
- `npm run build` in `/Users/azu/Desktop/VTubeAwards/frontend`
- `dotnet build Backend/Backend.csproj` in `/Users/azu/Desktop/VTubeAwards`
### Browserpruefung lokal
- Nominierung mit einem Link in einer Kategorie.
- Nominierung mit zwei Links in einer Kategorie.
- Nominierung mit drei Links in einer Kategorie.
- Doppelte Links in derselben Kategorie.
- Leere Kategorien ueberspringen.
- Voting-Kategorie wechseln.
- Weiter-Button im Voting verwenden.
- Fehlende-Stimmen-Hinweis vor dem Absenden pruefen.
- Vote speichern, erneut oeffnen, aendern und erneut speichern.
- Mobile Layout bei 360px, 390px und 768px ohne horizontales Overflow pruefen.
### API-Smoke-Checks
- `GET /api/public/overview`
- `GET /api/public/seasons/{year}/categories`
- `GET /api/public/seasons/{year}/me`
- `POST /api/public/nominations` mit authentifizierter Session.
- `POST /api/public/votes` mit authentifizierter Session.
## Umsetzungshinweise
- Phase 1 sollte moeglichst frontendlastig bleiben und bestehende Backend-Faehigkeiten nutzen.
- Wenn Datenmodell-Migrationen noetig werden, sollten sie erst mit Phase 2 eingefuehrt werden.
- Alle Public-UI-Aenderungen muessen gegen echte Backenddaten laufen, nicht gegen Demo-State.
- Fuer echte UI-Aenderungen reicht ein Build nicht aus; der Flow muss im Browser geprueft werden.
- Kleine Vue-Komponenten bevorzugen, besonders beim Umbau des Nominierungs- und Votingmodals.
+3
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import AppShellAccountModals from './AppShellAccountModals.vue'
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
import { privacyContentToHtml } from '../lib/privacyContent'
import { useAuthStore } from '../stores/auth'
import { useAwardsStore } from '../stores/awards'
@@ -13,6 +14,8 @@ const router = useRouter()
const authStore = useAuthStore()
const awardsStore = useAwardsStore()
useBodyScrollLock(() => awardsStore.loading)
const loginForm = reactive({
twitchUserId: 'jayuhime_viewer',
displayName: 'Jayuhime',
@@ -1,5 +1,5 @@
<template>
<Modal :open="open" :title="title" subtitle="Anzeigename und Handle sind Pflicht." @close="$emit('close')">
<Modal :open="open" size="lg" :title="title" subtitle="Anzeigename und Handle sind Pflicht. Annahme und Clip bereiten das Voting vor." @close="$emit('close')">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
@@ -30,6 +30,70 @@
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
</label>
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
<div class="mb-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
<p class="mt-1 text-sm text-slate-500">Kontakt und Zusage werden hier gepflegt, nicht im Review-Text versteckt.</p>
</div>
<div class="grid gap-2 sm:grid-cols-4">
<button
v-for="status in acceptanceStatusOptions"
:key="status.value"
type="button"
class="rounded-2xl border px-3 py-2 text-left text-sm transition"
:class="form.acceptanceStatus === status.value ? 'border-violet-400 bg-white text-violet-800 shadow-sm' : 'border-violet-100 bg-white/70 text-slate-600 hover:border-violet-200'"
@click="$emit('update:acceptanceStatus', status.value)"
>
<strong class="block text-sm">{{ status.label }}</strong>
<span class="text-xs">{{ status.description }}</span>
</button>
</div>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Teamnotiz</span>
<textarea
:value="form.acceptanceNote"
rows="3"
maxlength="500"
class="w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Discord kontaktiert, wartet auf Zusage."
@input="$emit('update:acceptanceNote', ($event.target as HTMLTextAreaElement).value)"
/>
</label>
<p v-if="form.acceptanceStatus === 'declined'" class="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
Diese Person ist nicht voting-bereit. Bitte Ersatz prüfen oder Kandidat entfernen.
</p>
</section>
<section class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Clip / Compilation</p>
<p class="mt-1 text-sm text-slate-500">Einzelner Clip oder Compilation YouTube- oder Twitch-Link.</p>
</div>
<a
v-if="form.clipCompilationUrl"
:href="form.clipCompilationUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 hover:bg-violet-50"
>
Link öffnen
</a>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Link</span>
<input :value="form.clipCompilationUrl" type="url" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="https://youtu.be/..." @input="$emit('update:clipCompilationUrl', ($event.target as HTMLInputElement).value)" />
</label>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel im Voting</span>
<input :value="form.clipCompilationTitle" type="text" maxlength="200" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Best-of Clip für das Voting" @input="$emit('update:clipCompilationTitle', ($event.target as HTMLInputElement).value)" />
</label>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Embed-Status</span>
<NativeSelect :model-value="form.clipEmbedStatus" :options="clipEmbedStatusOptions" @update:model-value="$emit('update:clipEmbedStatus', String($event))" />
</label>
</section>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
@@ -52,9 +116,17 @@ defineProps<{
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string
clipCompilationUrl: string
clipCompilationTitle: string
clipCompilationPlatform: string
clipEmbedStatus: string
}
categoryOptions: Array<{ label: string; value: number }>
candidatePlatformOptions: SocialIconOption[]
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
clipEmbedStatusOptions: Array<{ label: string; value: string }>
selectedPlatformValue: string
canSave: boolean
saving: boolean
@@ -67,6 +139,12 @@ defineEmits<{
'update:displayName': [value: string]
'update:channelSlug': [value: string]
'update:platform': [value: string]
'update:acceptanceStatus': [value: string]
'update:acceptanceNote': [value: string]
'update:clipCompilationUrl': [value: string]
'update:clipCompilationTitle': [value: string]
'update:clipCompilationPlatform': [value: string]
'update:clipEmbedStatus': [value: string]
'platform-selection': [value: string]
}>()
</script>
@@ -15,7 +15,12 @@
:options="categoryFilterOptions"
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<NativeSelect
:model-value="readinessFilter"
:options="readinessFilterOptions"
@update:model-value="$emit('update:readinessFilter', String($event))"
/>
<Button v-if="search || categoryFilter || readinessFilter !== 'all'" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="$emit('open-create')">
@@ -33,12 +38,15 @@ import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
search: string
categoryFilter: number | null
readinessFilter: string
categoryFilterOptions: Array<{ label: string; value: number | null }>
readinessFilterOptions: Array<{ label: string; value: string }>
}>()
defineEmits<{
'update:search': [value: string]
'update:categoryFilter': [value: number | null]
'update:readinessFilter': [value: string]
'clear-filters': []
'open-create': []
}>()
@@ -1,8 +1,10 @@
<template>
<div>
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<div class="hidden grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_130px_minmax(0,1.2fr)_110px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 xl:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Annahme</span>
<span>Clip-Compilation</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
@@ -11,7 +13,7 @@
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_130px_minmax(0,1.2fr)_110px_96px] xl:items-center xl:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
@@ -23,6 +25,14 @@
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat in dieser Kategorie
</p>
<p
v-for="notice in candidateWorkflowNotices[candidate.id] ?? []"
:key="notice.message"
class="mt-1 text-xs font-semibold"
:class="notice.mode === 'block' ? 'text-rose-700' : 'text-amber-700'"
>
{{ notice.mode === 'block' ? 'Regel blockiert' : 'Regelhinweis' }}: {{ notice.message }}
</p>
</div>
</div>
<div class="min-w-0">
@@ -30,6 +40,31 @@
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<div>
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-semibold" :class="acceptanceClass(candidate.acceptanceStatus)">
{{ acceptanceLabel(candidate.acceptanceStatus) }}
</span>
<p v-if="candidate.acceptanceNote" class="mt-1 line-clamp-2 text-xs text-slate-500">{{ candidate.acceptanceNote }}</p>
</div>
<div class="min-w-0">
<template v-if="candidate.clipCompilationUrl && candidate.clipEmbedStatus !== 'blocked'">
<a
:href="candidate.clipCompilationUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold text-violet-700 hover:text-violet-900"
>
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ candidate.clipCompilationTitle || 'Compilation öffnen' }}</span>
</a>
<p class="mt-1 text-xs text-slate-500">
{{ candidate.clipCompilationPlatform || 'Plattform offen' }} · {{ embedLabel(candidate.clipEmbedStatus) }}
</p>
</template>
<p v-else class="text-xs font-semibold text-amber-700">
{{ candidate.clipEmbedStatus === 'blocked' ? 'Clip nicht nutzbar' : 'Clip fehlt' }}
</p>
</div>
<div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
@@ -71,7 +106,7 @@
</template>
<script setup lang="ts">
import { Pencil, Trash2, UserPlus } from '@lucide/vue'
import { ExternalLink, Pencil, Trash2, UserPlus } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
@@ -87,6 +122,8 @@ const props = defineProps<{
rangeEnd: number
categoryLabelMap: Record<number, string>
duplicateCandidateKeys: Map<string, number>
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
}>()
defineEmits<{
@@ -100,4 +137,22 @@ function isDuplicate(candidate: AdminCandidateItem) {
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
}
function acceptanceLabel(value: string) {
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
}
function acceptanceClass(value: string) {
if (value === 'accepted') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (value === 'contacted') return 'border-sky-200 bg-sky-50 text-sky-700'
if (value === 'declined') return 'border-rose-200 bg-rose-50 text-rose-700'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
function embedLabel(value: string) {
if (value === 'embeddable') return 'Einbettbar'
if (value === 'link_only') return 'Nur Link'
if (value === 'blocked') return 'Nicht nutzbar'
return 'Embed prüfen'
}
</script>
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
const store = useAwardsStore()
const loading = ref(false)
const saving = ref(false)
const statusMessage = ref('')
const statusError = ref('')
const editingSponsorId = ref<number | null>(null)
const showactNotes = reactive<Record<number, string>>({})
const settingsForm = reactive({
showactApplicationsEnabled: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
})
const sponsorForm = reactive<UpsertSponsorPayload>({
name: '',
websiteUrl: '',
logoUrl: '',
description: '',
tier: 'Partner',
sortOrder: 0,
isVisible: true,
})
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
watch(
() => store.adminOptionalFeatureSettings,
(settings) => {
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
settingsForm.sponsorsVisible = settings.sponsorsVisible
},
{ immediate: true, deep: true },
)
function resetSponsorForm() {
editingSponsorId.value = null
sponsorForm.name = ''
sponsorForm.websiteUrl = ''
sponsorForm.logoUrl = ''
sponsorForm.description = ''
sponsorForm.tier = 'Partner'
sponsorForm.sortOrder = store.adminSponsors.length + 1
sponsorForm.isVisible = true
}
function editSponsor(sponsor: AdminSponsorItem) {
editingSponsorId.value = sponsor.id
sponsorForm.name = sponsor.name
sponsorForm.websiteUrl = sponsor.websiteUrl
sponsorForm.logoUrl = sponsor.logoUrl
sponsorForm.description = sponsor.description
sponsorForm.tier = sponsor.tier
sponsorForm.sortOrder = sponsor.sortOrder
sponsorForm.isVisible = sponsor.isVisible
}
async function ensureSeasonId() {
if (seasonId.value) return seasonId.value
await store.loadHomeData()
return store.overview.seasonId
}
async function loadLandingExtras() {
loading.value = true
statusError.value = ''
try {
const resolvedSeasonId = await ensureSeasonId()
await Promise.all([
store.loadAdminOptionalFeatureSettings(),
resolvedSeasonId ? store.loadAdminExtras(resolvedSeasonId) : Promise.resolve(),
])
for (const application of store.adminShowactApplications) {
showactNotes[application.id] = application.reviewNote ?? ''
}
resetSponsorForm()
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht geladen werden.'
} finally {
loading.value = false
}
}
async function saveLandingExtrasSettings() {
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.updateAdminOptionalFeatureSettings({
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
sponsorsVisible: settingsForm.sponsorsVisible,
})
statusMessage.value = 'Landingpage-Extras wurden gespeichert.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function saveSponsor() {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
if (editingSponsorId.value) {
await store.updateAdminSponsor(editingSponsorId.value, resolvedSeasonId, sponsorForm)
statusMessage.value = 'Sponsor wurde aktualisiert.'
} else {
await store.createAdminSponsor(resolvedSeasonId, sponsorForm)
statusMessage.value = 'Sponsor wurde angelegt.'
}
resetSponsorForm()
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function deleteSponsor(sponsor: AdminSponsorItem) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.deleteAdminSponsor(sponsor.id, resolvedSeasonId)
if (editingSponsorId.value === sponsor.id) resetSponsorForm()
statusMessage.value = 'Sponsor wurde geloescht.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
} finally {
saving.value = false
}
}
async function updateShowactStatus(applicationId: number, status: string) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.updateAdminShowactStatus(applicationId, resolvedSeasonId, {
status,
reviewNote: showactNotes[applicationId] ?? '',
})
statusMessage.value = 'Showact-Status wurde gespeichert.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function deleteShowact(applicationId: number, artistName: string) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.deleteAdminShowactApplication(applicationId, resolvedSeasonId)
delete showactNotes[applicationId]
statusMessage.value = 'Showact-Bewerbung wurde geloescht.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
} finally {
saving.value = false
}
}
function statusLabel(status: string) {
return {
pending: 'Offen',
shortlisted: 'Shortlist',
accepted: 'Angenommen',
rejected: 'Abgelehnt',
}[status] ?? status
}
onMounted(loadLandingExtras)
</script>
<template>
<Card id="content-landing-extras" class="p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Landingpage Extras</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen.
</p>
</div>
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras">
<Settings2 class="h-4 w-4" />
{{ loading ? 'Laedt ...' : 'Neu laden' }}
</Button>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-fuchsia-600">Showacts</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminShowactApplications.length }}</p>
<p class="text-sm text-slate-500">{{ pendingShowacts }} offen</p>
</div>
<div class="rounded-2xl border border-sky-100 bg-sky-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-sky-600">Sponsoren</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminSponsors.length }}</p>
<p class="text-sm text-slate-500">{{ visibleSponsors }} sichtbar</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-600">Saison</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.overview.year || store.adminSeasonDetail.year || '' }}</p>
<p class="text-sm text-slate-500">Landingpage-Jahr</p>
</div>
</div>
<section class="mt-6 grid gap-3 lg:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Public-Formular auf der Landingpage anzeigen.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.showactApplicationsEnabled"
label="Showact-Bewerbungen aktivieren"
active-label="Offen"
inactive-label="Zu"
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.sponsorsVisible"
label="Sponsoren anzeigen"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="settingsForm.sponsorsVisible = $event"
/>
</div>
</div>
</section>
<label class="mt-4 block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span>
<textarea
v-model="settingsForm.showactApplicationDisabledMessage"
rows="3"
maxlength="240"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="mt-4 flex flex-wrap items-center gap-3">
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
</Button>
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
</div>
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2">
<Mic2 class="h-5 w-5 text-fuchsia-700" />
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbungen</h3>
</div>
</div>
<div v-if="store.adminShowactApplications.length === 0" class="p-4 text-sm text-slate-500">
Noch keine Bewerbungen fuer dieses Jahr.
</div>
<div v-else class="divide-y divide-violet-100">
<article v-for="application in store.adminShowactApplications" :key="application.id" class="space-y-3 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<h4 class="font-bold text-slate-950">{{ application.artistName }}</h4>
<span class="rounded-full bg-fuchsia-100 px-3 py-1 text-xs font-bold text-fuchsia-700">{{ statusLabel(application.status) }}</span>
</div>
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" :disabled="saving" @click="updateShowactStatus(application.id, 'shortlisted')">Shortlist</Button>
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
</div>
<textarea
v-model="showactNotes[application.id]"
rows="2"
maxlength="500"
placeholder="Interne Review-Notiz"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</article>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2">
<Handshake class="h-5 w-5 text-sky-700" />
<h3 class="text-base font-bold text-slate-900">Sponsoren</h3>
</div>
</div>
<form class="space-y-3 p-4" @submit.prevent="saveSponsor">
<input v-model="sponsorForm.name" required maxlength="120" placeholder="Sponsor-Name" class="w-full rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="sponsorForm.websiteUrl" maxlength="500" placeholder="Website URL" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="sponsorForm.logoUrl" maxlength="500" placeholder="Logo URL" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-[1fr_120px]">
<input v-model="sponsorForm.tier" maxlength="80" placeholder="Tier" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model.number="sponsorForm.sortOrder" type="number" min="0" max="9999" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<textarea v-model="sponsorForm.description" rows="3" maxlength="500" placeholder="Beschreibung" class="w-full rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
<input v-model="sponsorForm.isVisible" type="checkbox" class="h-4 w-4 rounded border-violet-200 text-violet-600" />
Public anzeigen
</label>
<div class="flex flex-wrap gap-2">
<Button class="gap-2" :disabled="saving">
<Save class="h-4 w-4" />
{{ editingSponsorId ? 'Sponsor speichern' : 'Sponsor anlegen' }}
</Button>
<Button type="button" variant="ghost" :disabled="saving" @click="resetSponsorForm">
<Plus class="h-4 w-4" />
Neu
</Button>
</div>
</form>
<div class="divide-y divide-violet-100 border-t border-violet-100">
<article v-for="sponsor in store.adminSponsors" :key="sponsor.id" class="flex items-start justify-between gap-3 p-4">
<div class="min-w-0">
<p class="truncate text-sm font-bold text-slate-900">{{ sponsor.name }}</p>
<p class="mt-1 text-xs font-semibold text-slate-500">{{ sponsor.tier }} · Reihenfolge {{ sponsor.sortOrder }}</p>
<p class="mt-1 text-xs" :class="sponsor.isVisible ? 'text-emerald-700' : 'text-slate-500'">
{{ sponsor.isVisible ? 'Public sichtbar' : 'Verborgen' }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button size="sm" variant="ghost" @click="editSponsor(sponsor)">Bearbeiten</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteSponsor(sponsor)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</article>
</div>
</div>
</section>
</Card>
</template>
@@ -31,6 +31,10 @@
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Sponsoren & Partner URL</span>
<input v-model="form.sponsorsUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Showacts URL</span>
<input v-model="form.showactsUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div>
<div class="mt-7 space-y-5">
<div class="space-y-3">
@@ -75,6 +79,20 @@
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'showacts')">
<Eye class="h-4 w-4" />
Showacts Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.showactsContent"
label="Showacts Inhalt"
placeholder="Informationen zu Showact-Bewerbungen, Ablauf und Kontakt..."
min-height-class="min-h-[260px]"
/>
</div>
</div>
</Card>
</template>
@@ -87,7 +105,7 @@ import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors' | 'showacts'
const props = defineProps<{
form: AdminContentForm
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Loader2, Plus, Save, Trash2, X } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { useAwardsStore } from '../../stores/awards'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const store = useAwardsStore()
const loading = ref(false)
const saving = ref(false)
const entries = ref<string[]>([])
const newUrl = ref('')
const success = ref('')
const error = ref('')
const normalizedEntries = computed(() =>
entries.value
.map((entry) => entry.trim())
.filter(Boolean),
)
const canSave = computed(() => !loading.value && !saving.value && normalizedEntries.value.length > 0)
watchAdminToast(success, error)
watch(
() => props.open,
(open) => {
if (open) {
void loadBlacklist()
}
},
{ immediate: true },
)
async function loadBlacklist() {
loading.value = true
error.value = ''
try {
const response = await store.loadAdminNominationLinkBlacklist()
entries.value = response.entries.map((entry) => entry.url)
} catch (loadError) {
error.value = loadError instanceof Error ? loadError.message : 'Blacklist konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
function addEntry() {
const url = newUrl.value.trim()
if (!url) return
entries.value = [...entries.value, url]
newUrl.value = ''
}
function removeEntry(index: number) {
entries.value = entries.value.filter((_, entryIndex) => entryIndex !== index)
}
async function saveBlacklist() {
if (!canSave.value) return
saving.value = true
success.value = ''
error.value = ''
try {
const response = await store.updateAdminNominationLinkBlacklist({ urls: normalizedEntries.value })
entries.value = response.entries.map((entry) => entry.url)
success.value = 'Link-Blacklist wurde gespeichert.'
} catch (saveError) {
error.value = saveError instanceof Error ? saveError.message : 'Blacklist konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
</script>
<template>
<Modal
:open="open"
title="Link-Blacklist"
subtitle="Blockiert generische oder unerwuenschte Stream-Links direkt bei der oeffentlichen Nominierung."
size="lg"
@close="emit('close')"
>
<div class="space-y-5">
<div class="rounded-2xl border border-amber-100 bg-amber-50/75 px-4 py-3 text-sm leading-6 text-amber-900">
Die Standardseiten von Twitch, Kick und YouTube sind bereits hinterlegt. User sollen direkte Kanal- oder Profil-Links einreichen, nicht die Startseite einer Plattform.
</div>
<div v-if="loading" class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-5 text-sm font-semibold text-violet-700">
<Loader2 class="h-4 w-4 animate-spin" />
Lade Blacklist ...
</div>
<div v-else class="space-y-3">
<div
v-for="(entry, index) in entries"
:key="`${entry}-${index}`"
class="grid gap-3 rounded-2xl border border-violet-100 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]"
>
<label class="block min-w-0">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Blockierter Link</span>
<input
v-model="entries[index]"
type="url"
class="mt-2 h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://example.com/"
/>
</label>
<Button type="button" variant="ghost" size="sm" class="self-end gap-2 rounded-2xl text-rose-700 hover:text-rose-800" @click="removeEntry(index)">
<Trash2 class="h-4 w-4" />
Entfernen
</Button>
</div>
<div class="grid gap-3 rounded-2xl border border-dashed border-violet-200 bg-violet-50/45 p-4 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="block min-w-0">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Neuer Link</span>
<input
v-model="newUrl"
type="url"
class="mt-2 h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://www.twitch.tv/"
@keydown.enter.prevent="addEntry"
/>
</label>
<Button type="button" variant="secondary" class="self-end gap-2 rounded-2xl" :disabled="!newUrl.trim()" @click="addEntry">
<Plus class="h-4 w-4" />
Hinzufügen
</Button>
</div>
</div>
<p v-if="entries.length === 0 && !loading" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
Die Blacklist darf nicht leer sein, damit die generischen Plattform-Links weiter geblockt bleiben.
</p>
</div>
<template #footer>
<Button type="button" variant="ghost" class="gap-2" :disabled="saving" @click="emit('close')">
<X class="h-4 w-4" />
Schließen
</Button>
<Button type="button" class="gap-2" :disabled="!canSave" @click="saveBlacklist">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Blacklist speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -20,6 +20,7 @@ const emit = defineEmits<{
const {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -39,6 +40,7 @@ const {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
@@ -153,10 +155,12 @@ onBeforeUnmount(() => {
:signal-summary="selectedNominationSignalSummary"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { Film, Settings2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
loading: boolean
saving: boolean
canManage: boolean
summary: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
pendingClipCount: number
totalClipCount: number
}
disabledMessage: string
}>()
const emit = defineEmits<{
configure: []
}>()
</script>
<template>
<Card class="overflow-hidden">
<section class="p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Optionale Workflows</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<Film class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Clip-Einreichungen steuern</h2>
</div>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Clip-Einreichung und Clip-Review sind optionale Betriebsfunktionen. Wenn deaktiviert, bleibt der Award-Workflow ohne Clip-Pflicht nutzbar.
</p>
</div>
<Button class="gap-2" :disabled="loading || saving || !canManage" @click="emit('configure')">
<Settings2 class="h-4 w-4" />
Clip-Workflow konfigurieren
</Button>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipSubmissionsEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'"
>
{{ summary.clipSubmissionsEnabled ? 'Clips aktiv' : 'Clips deaktiviert' }}
</span>
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipSubmissionsEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
{{ summary.clipSubmissionsEnabled ? 'Public sichtbar' : 'Public ausgeblendet' }}
</span>
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipReviewEnabled ? 'bg-violet-100 text-violet-700' : 'bg-slate-100 text-slate-600'"
>
{{ summary.clipReviewEnabled ? 'Admin-Review sichtbar' : 'Admin-Review aus' }}
</span>
<span class="rounded-full bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-700">
Review offen: {{ summary.pendingClipCount }}
</span>
<span class="rounded-full bg-sky-100 px-3 py-1.5 text-xs font-semibold text-sky-700">
Bestand: {{ summary.totalClipCount }}
</span>
</div>
<p v-if="!summary.clipSubmissionsEnabled" class="mt-4 rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
{{ disabledMessage || 'Neue Clip-Einreichungen sind geschlossen. Bestehende Clips und Review-Daten bleiben erhalten.' }}
</p>
</section>
</Card>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { Save } from '@lucide/vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
defineProps<{
open: boolean
form: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipAdminMenuVisible: boolean
clipSubmissionDisabledMessage: string
}
saving: boolean
dirty: boolean
canManage: boolean
}>()
const emit = defineEmits<{
close: []
save: []
'update:clipSubmissionsEnabled': [value: boolean]
'update:clipReviewEnabled': [value: boolean]
'update:clipAdminMenuVisible': [value: boolean]
'update:clipSubmissionDisabledMessage': [value: string]
}>()
</script>
<template>
<Modal
:open="open"
title="Clip-Workflow"
subtitle="Steuert, ob Besucher Clips einreichen können und ob der Clip-Bereich im Admin als aktiver Workflow erscheint."
size="lg"
@close="emit('close')"
>
<div class="space-y-5">
<section v-if="!canManage" class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Du kannst den Clip-Workflow ansehen, aber nicht bearbeiten.
</section>
<section class="grid gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Public-Einreichung</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Besucher können während der passenden Phase Clips einreichen.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipSubmissionsEnabled"
label="Clip-Einreichung aktivieren"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('update:clipSubmissionsEnabled', $event)"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Admin-Review</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Clip-Bestand und Alt-Einreichungen bleiben als optionale Inbox sichtbar.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipReviewEnabled"
label="Admin-Review anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipReviewEnabled', $event)"
/>
</div>
</div>
</section>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Menüpunkt Clips" im Admin</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Blendet den Clips-Menüpunkt im Admin-Panel ein oder aus unabhängig von Einreichung und Review.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipAdminMenuVisible"
label="Clips-Menüpunkt anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipAdminMenuVisible', $event)"
/>
</div>
</div>
<label class="block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei deaktivierter Einreichung</span>
<textarea
:value="form.clipSubmissionDisabledMessage"
rows="3"
maxlength="240"
:disabled="!canManage"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('update:clipSubmissionDisabledMessage', ($event.target as HTMLTextAreaElement).value)"
/>
<span class="text-xs font-semibold text-slate-500">
{{ form.clipSubmissionDisabledMessage.length }}/240 Zeichen
</span>
</label>
<section class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-800">
Nominierung, Voting und Gewinnerpflege laufen unabhängig davon weiter. Bestehende Clips werden nicht gelöscht.
</section>
</div>
<template #footer>
<Button variant="ghost" :disabled="saving" @click="emit('close')">Schließen</Button>
<Button class="gap-2" :disabled="saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Clip-Workflow speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, Trash2 } from '@lucide/vue'
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
@@ -24,6 +24,7 @@ defineProps<{
} | null
streamUrl?: string
canApproveSelected: boolean
blacklistSaving: boolean
selectedPlatformValue: (platform: string) => string
}>()
@@ -31,6 +32,7 @@ const emit = defineEmits<{
'platform-change': [value: string]
approve: [nominationId: number]
reject: [nominationId: number]
'blacklist-link': [streamUrl: string]
}>()
</script>
@@ -51,14 +53,28 @@ const emit = defineEmits<{
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="mt-2 inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<div class="mt-2 flex flex-wrap items-center gap-2">
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
:disabled="blacklistSaving"
@click="emit('blacklist-link', streamUrl)"
>
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
<Ban v-else class="h-3.5 w-3.5" />
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
</Button>
</div>
</div>
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { Save, ShieldCheck } from '@lucide/vue'
import type { AdminWorkflowRule } from '../../types/awards'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
rules: AdminWorkflowRule[]
loading: boolean
saving: boolean
dirty: boolean
canManage: boolean
summary: {
active: number
blocking: number
total: number
}
}>()
const emit = defineEmits<{
updateRule: [ruleKey: string, patch: Partial<AdminWorkflowRule>]
save: []
}>()
</script>
<template>
<Card class="overflow-visible">
<section class="border-b border-violet-100 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<ShieldCheck class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Award-Workflow-Regeln</h2>
</div>
<p class="mt-1 max-w-3xl text-sm leading-6 text-slate-500">
Diese Regeln steuern Vorbereitung, finale Kandidat:innen und Gewinnervergabe der ausgewählten Saison.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
{{ summary.active }}/{{ summary.total }} aktiv
</span>
<span class="rounded-full bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700">
{{ summary.blocking }} blockierend
</span>
<Button class="gap-2" :disabled="loading || saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
</div>
</section>
<section v-if="!canManage" class="border-b border-amber-100 bg-amber-50 px-5 py-3 text-sm font-semibold text-amber-800">
Du kannst die Workflow-Regeln ansehen, aber nicht bearbeiten.
</section>
<section v-if="loading" class="p-5">
<p class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Workflow-Regeln werden geladen.
</p>
</section>
<section v-else class="grid gap-3 p-5">
<article
v-for="rule in rules"
:key="rule.key"
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
>
<div class="grid gap-4 xl:grid-cols-[minmax(220px,1fr)_120px_170px_170px] xl:items-end">
<div class="min-w-0">
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
</div>
<label v-if="rule.key !== 'winner_requires_clip'" class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Limit</span>
<input
:value="rule.limit"
type="number"
min="1"
max="50"
:disabled="!canManage"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('updateRule', rule.key, { limit: Number(($event.target as HTMLInputElement).value) })"
>
</label>
<div v-else class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold leading-5 text-slate-500">
Ja/Nein-Regel<br>
<span class="font-normal">Limit wird hier nicht verwendet.</span>
</div>
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Modus</span>
<NativeSelect
:model-value="rule.mode"
:disabled="!canManage"
:options="[
{ label: 'Blockieren', value: 'block' },
{ label: 'Warnen', value: 'warn' },
]"
@update:model-value="emit('updateRule', rule.key, { mode: String($event) })"
/>
</label>
<AdminSettingsToggle
:model-value="rule.enabled"
:label="`${rule.label} aktivieren`"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('updateRule', rule.key, { enabled: $event })"
/>
</div>
</article>
<p v-if="rules.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Workflow-Regeln verfügbar.
</p>
</section>
</Card>
</template>
@@ -24,6 +24,8 @@ export type AdminContentForm = {
contactContent: string
sponsorsUrl: string
sponsorsContent: string
showactsUrl: string
showactsContent: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -2,16 +2,45 @@ import { computed, reactive, ref, watch } from 'vue'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
interface CandidateForm {
categoryId: number
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string
clipCompilationUrl: string
clipCompilationTitle: string
clipCompilationPlatform: string
clipEmbedStatus: string
}
const pageSize = 10
const maxFinalistsRuleKey = 'max_finalists_per_category'
const maxCandidateAppearancesRuleKey = 'max_candidate_appearances'
const acceptanceStatusOptions = [
{ label: 'Offen', value: 'open', description: 'Noch nicht kontaktiert' },
{ label: 'Angefragt', value: 'contacted', description: 'Kontakt läuft' },
{ label: 'Angenommen', value: 'accepted', description: 'nimmt teil' },
{ label: 'Abgesagt', value: 'declined', description: 'nicht voting-bereit' },
]
const clipEmbedStatusOptions = [
{ label: 'Noch nicht geprüft', value: 'unchecked' },
{ label: 'Einbettbar', value: 'embeddable' },
{ label: 'Nur Link', value: 'link_only' },
{ label: 'Nicht nutzbar', value: 'blocked' },
]
const readinessFilterOptions = [
{ label: 'Alle', value: 'all' },
{ label: 'Offen', value: 'open' },
{ label: 'Angefragt', value: 'contacted' },
{ label: 'Angenommen', value: 'accepted' },
{ label: 'Abgesagt', value: 'declined' },
{ label: 'Clip fehlt', value: 'missing_clip' },
{ label: 'Embed prüfen', value: 'embed_review' },
]
export function useAdminCandidateManager() {
const store = useAwardsStore()
@@ -21,13 +50,26 @@ export function useAdminCandidateManager() {
const adminError = ref('')
const search = ref('')
const categoryFilter = ref<number | null>(null)
const readinessFilter = ref('all')
const page = ref(1)
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const candidateToDelete = ref<AdminCandidateItem | null>(null)
const form = reactive<CandidateForm>({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const form = reactive<CandidateForm>({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
acceptanceStatus: 'open',
acceptanceNote: '',
clipCompilationUrl: '',
clipCompilationTitle: '',
clipCompilationPlatform: '',
clipEmbedStatus: 'unchecked',
})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
@@ -51,6 +93,23 @@ export function useAdminCandidateManager() {
const duplicateCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
)
const candidateWorkflowNotices = computed(() => buildCandidateWorkflowNotices(
seasonDetail.value.candidates,
workflowRules.value,
))
const acceptedCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus === 'accepted').length,
)
const clipReadyCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasUsableCompilation(candidate)).length,
)
const actionNeededCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) =>
candidate.acceptanceStatus !== 'accepted'
|| !hasUsableCompilation(candidate)
|| candidate.clipEmbedStatus === 'unchecked',
).length,
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
@@ -59,6 +118,20 @@ export function useAdminCandidateManager() {
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
}
if (readinessFilter.value !== 'all') {
list = list.filter((candidate) => {
if (readinessFilter.value === 'missing_clip') {
return !hasUsableCompilation(candidate)
}
if (readinessFilter.value === 'embed_review') {
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus === 'unchecked'
}
return candidate.acceptanceStatus === readinessFilter.value
})
}
if (query) {
list = list.filter((candidate) =>
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
@@ -79,12 +152,19 @@ export function useAdminCandidateManager() {
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim()),
Boolean(
selectedSeasonId.value
&& form.categoryId
&& form.displayName.trim()
&& form.channelSlug.trim()
&& form.platform.trim()
&& isValidOptionalUrl(form.clipCompilationUrl),
),
)
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
watch([search, categoryFilter, readinessFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
@@ -97,6 +177,16 @@ export function useAdminCandidateManager() {
function clearFilters() {
search.value = ''
categoryFilter.value = null
readinessFilter.value = 'all'
}
function resetPreparationForm() {
form.acceptanceStatus = 'open'
form.acceptanceNote = ''
form.clipCompilationUrl = ''
form.clipCompilationTitle = ''
form.clipCompilationPlatform = ''
form.clipEmbedStatus = 'unchecked'
}
function openCreate() {
@@ -107,6 +197,7 @@ export function useAdminCandidateManager() {
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
resetPreparationForm()
modalOpen.value = true
}
@@ -118,6 +209,12 @@ export function useAdminCandidateManager() {
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
form.acceptanceStatus = candidate.acceptanceStatus || 'open'
form.acceptanceNote = candidate.acceptanceNote ?? ''
form.clipCompilationUrl = candidate.clipCompilationUrl ?? ''
form.clipCompilationTitle = candidate.clipCompilationTitle ?? ''
form.clipCompilationPlatform = candidate.clipCompilationPlatform ?? ''
form.clipEmbedStatus = candidate.clipEmbedStatus || 'unchecked'
modalOpen.value = true
}
@@ -143,10 +240,10 @@ export function useAdminCandidateManager() {
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
await store.createAdminCandidate(selectedSeasonId.value, buildCandidatePayload())
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, buildCandidatePayload())
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
@@ -157,6 +254,22 @@ export function useAdminCandidateManager() {
}
}
function buildCandidatePayload() {
const clipUrl = form.clipCompilationUrl.trim()
return {
categoryId: form.categoryId,
displayName: form.displayName,
channelSlug: form.channelSlug,
platform: form.platform,
acceptanceStatus: form.acceptanceStatus,
acceptanceNote: form.acceptanceNote.trim() || null,
clipCompilationUrl: clipUrl || null,
clipCompilationTitle: clipUrl ? form.clipCompilationTitle.trim() || null : null,
clipCompilationPlatform: clipUrl ? form.clipCompilationPlatform.trim() || null : null,
clipEmbedStatus: clipUrl ? form.clipEmbedStatus : 'unchecked',
}
}
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) {
return
@@ -184,12 +297,17 @@ export function useAdminCandidateManager() {
adminError,
search,
categoryFilter,
readinessFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
clipReadyCount,
actionNeededCount,
filteredCandidates,
pagedCandidates,
totalPages,
@@ -201,6 +319,9 @@ export function useAdminCandidateManager() {
canSave,
candidatePlatformOptions,
selectedPlatformValue,
acceptanceStatusOptions,
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
clearFilters,
openCreate,
@@ -211,6 +332,59 @@ export function useAdminCandidateManager() {
}
}
function buildCandidateWorkflowNotices(candidates: AdminCandidateItem[], rules: AdminWorkflowRule[]) {
const notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>> = {}
const activeCandidates = candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined')
const finalistsRule = rules.find((rule) => rule.key === maxFinalistsRuleKey)
const appearancesRule = rules.find((rule) => rule.key === maxCandidateAppearancesRuleKey)
if (finalistsRule?.enabled) {
const categoryCounts = new Map<number, number>()
for (const candidate of activeCandidates) {
categoryCounts.set(candidate.categoryId, (categoryCounts.get(candidate.categoryId) ?? 0) + 1)
}
for (const candidate of activeCandidates) {
const count = categoryCounts.get(candidate.categoryId) ?? 0
if (count > finalistsRule.limit) {
addCandidateNotice(notices, candidate.id, finalistsRule, `${count}/${finalistsRule.limit} finale Kandidat:innen in dieser Kategorie.`)
}
}
}
if (appearancesRule?.enabled) {
const identityCounts = new Map<string, number>()
for (const candidate of activeCandidates) {
const key = candidateIdentityKey(candidate)
identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1)
}
for (const candidate of activeCandidates) {
const count = identityCounts.get(candidateIdentityKey(candidate)) ?? 0
if (count > appearancesRule.limit) {
addCandidateNotice(notices, candidate.id, appearancesRule, `${count}/${appearancesRule.limit} Kandidaturen für diese Person.`)
}
}
}
return notices
}
function addCandidateNotice(
notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>,
candidateId: number,
rule: AdminWorkflowRule,
message: string,
) {
const mode = rule.mode === 'warn' ? 'warn' : 'block'
notices[candidateId] = [...(notices[candidateId] ?? []), { mode, message }]
}
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
@@ -220,3 +394,19 @@ function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys:
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1
}
function hasUsableCompilation(candidate: AdminCandidateItem) {
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus !== 'blocked'
}
function isValidOptionalUrl(value: string) {
const trimmed = value.trim()
if (!trimmed) return true
try {
const url = new URL(trimmed)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
@@ -25,6 +25,23 @@ export function useAdminClipManager() {
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const optionalFeatures = computed(() => store.adminOptionalFeatureSettings)
const clipSubmissionsEnabled = computed(() => optionalFeatures.value.clipSubmissionsEnabled)
const clipReviewEnabled = computed(() => optionalFeatures.value.clipReviewEnabled)
const clipDisabledMessage = computed(() => optionalFeatures.value.clipSubmissionDisabledMessage)
const clipWorkflowStatusLabel = computed(() => {
if (clipSubmissionsEnabled.value && clipReviewEnabled.value) return 'Public-Einreichung und Review aktiv'
if (clipSubmissionsEnabled.value) return 'Public-Einreichung aktiv, Review ausgeblendet'
if (clipReviewEnabled.value) return 'Public aus, Review bleibt sichtbar'
return 'Clip-Workflow deaktiviert'
})
const clipWorkflowStatusClass = computed(() =>
clipSubmissionsEnabled.value
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: clipReviewEnabled.value || submissions.value.length > 0
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-slate-200 bg-slate-50 text-slate-600',
)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
@@ -188,6 +205,11 @@ export function useAdminClipManager() {
adminError,
clipToDelete,
reviewNotes,
clipSubmissionsEnabled,
clipReviewEnabled,
clipDisabledMessage,
clipWorkflowStatusLabel,
clipWorkflowStatusClass,
submissions,
categoryName,
clips,
@@ -22,6 +22,8 @@ function createEmptyForm(): AdminContentForm {
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
socialLinks: [],
faq: [],
}
@@ -83,6 +85,8 @@ export function useAdminContentManager() {
form.contactContent = settings.contactContent
form.sponsorsUrl = settings.sponsorsUrl
form.sponsorsContent = settings.sponsorsContent
form.showactsUrl = settings.showactsUrl
form.showactsContent = settings.showactsContent
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -249,6 +253,8 @@ export function useAdminContentManager() {
contactContent: privacyContentForStorage(form.contactContent),
sponsorsUrl: form.sponsorsUrl,
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
showactsUrl: form.showactsUrl,
showactsContent: privacyContentForStorage(form.showactsContent),
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
@@ -0,0 +1,123 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
const fallbackDisabledMessage = 'Clip-Einreichungen sind aktuell geschlossen.'
export function useAdminOptionalFeatures() {
const store = useAwardsStore()
const optionalFeaturesLoading = ref(false)
const optionalFeaturesSaving = ref(false)
const optionalFeaturesError = ref('')
const optionalFeaturesSuccess = ref('')
const optionalFeaturesModalOpen = ref(false)
const originalSnapshot = ref('')
const optionalFeaturesForm = reactive({
clipSubmissionsEnabled: false,
clipReviewEnabled: true,
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: fallbackDisabledMessage,
})
const pendingClipCount = computed(() =>
store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length,
)
const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.length)
const optionalFeaturesSummary = computed(() => ({
clipSubmissionsEnabled: optionalFeaturesForm.clipSubmissionsEnabled,
clipReviewEnabled: optionalFeaturesForm.clipReviewEnabled,
pendingClipCount: pendingClipCount.value,
totalClipCount: totalClipCount.value,
}))
const hasUnsavedOptionalFeatureChanges = computed(() =>
JSON.stringify(normalizedForm()) !== originalSnapshot.value,
)
function normalizedForm() {
return {
clipSubmissionsEnabled: optionalFeaturesForm.clipSubmissionsEnabled,
clipReviewEnabled: optionalFeaturesForm.clipReviewEnabled,
clipAdminMenuVisible: optionalFeaturesForm.clipAdminMenuVisible,
clipSubmissionDisabledMessage: normalizeDisabledMessage(optionalFeaturesForm.clipSubmissionDisabledMessage),
showactApplicationsEnabled: store.adminOptionalFeatureSettings.showactApplicationsEnabled,
showactApplicationDisabledMessage: store.adminOptionalFeatureSettings.showactApplicationDisabledMessage,
sponsorsVisible: store.adminOptionalFeatureSettings.sponsorsVisible,
}
}
function applyResponse(response = store.adminOptionalFeatureSettings) {
optionalFeaturesForm.clipSubmissionsEnabled = response.clipSubmissionsEnabled
optionalFeaturesForm.clipReviewEnabled = response.clipReviewEnabled
optionalFeaturesForm.clipAdminMenuVisible = response.clipAdminMenuVisible
optionalFeaturesForm.clipSubmissionDisabledMessage = normalizeDisabledMessage(response.clipSubmissionDisabledMessage)
originalSnapshot.value = JSON.stringify(normalizedForm())
}
async function loadOptionalFeatureSettings() {
optionalFeaturesLoading.value = true
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
try {
const response = await store.loadAdminOptionalFeatureSettings()
applyResponse(response)
} catch (error) {
optionalFeaturesError.value = error instanceof Error ? error.message : 'Optionale Workflows konnten nicht geladen werden.'
applyResponse()
} finally {
optionalFeaturesLoading.value = false
}
}
async function saveOptionalFeatureSettings() {
optionalFeaturesSaving.value = true
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
try {
const response = await store.updateAdminOptionalFeatureSettings(normalizedForm())
applyResponse(response)
optionalFeaturesSuccess.value = 'Clip-Workflow wurde gespeichert.'
optionalFeaturesModalOpen.value = false
} catch (error) {
optionalFeaturesError.value = error instanceof Error ? error.message : 'Clip-Workflow konnte nicht gespeichert werden.'
} finally {
optionalFeaturesSaving.value = false
}
}
function openOptionalFeaturesModal() {
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
optionalFeaturesModalOpen.value = true
}
function closeOptionalFeaturesModal() {
if (hasUnsavedOptionalFeatureChanges.value && !window.confirm('Ungespeicherte Clip-Workflow-Änderungen verwerfen?')) {
return
}
applyResponse()
optionalFeaturesModalOpen.value = false
}
onMounted(loadOptionalFeatureSettings)
return {
hasUnsavedOptionalFeatureChanges,
optionalFeaturesError,
optionalFeaturesForm,
optionalFeaturesLoading,
optionalFeaturesModalOpen,
optionalFeaturesSaving,
optionalFeaturesSuccess,
optionalFeaturesSummary,
closeOptionalFeaturesModal,
loadOptionalFeatureSettings,
openOptionalFeaturesModal,
saveOptionalFeatureSettings,
}
}
function normalizeDisabledMessage(value: string) {
const trimmed = value.trim()
return trimmed ? trimmed.slice(0, 240) : fallbackDisabledMessage
}
@@ -8,6 +8,7 @@ export function useAdminReviewsManager() {
const store = useAwardsStore()
const route = useRoute()
const reviewSaving = ref<number | null>(null)
const blacklistSaving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const reviewFilter = ref('')
@@ -207,6 +208,24 @@ export function useAdminReviewsManager() {
}
}
async function addStreamUrlToBlacklist(streamUrl: string) {
const url = streamUrl.trim()
if (!url || blacklistSaving.value) return
blacklistSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.addAdminNominationLinkBlacklistEntry({ url })
adminMessage.value = 'Link wurde zur Blacklist hinzugefügt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Link konnte nicht zur Blacklist hinzugefügt werden.'
} finally {
blacklistSaving.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as Element
if (
@@ -280,6 +299,7 @@ export function useAdminReviewsManager() {
return {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -299,6 +319,7 @@ export function useAdminReviewsManager() {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
@@ -19,6 +19,7 @@ export function useAdminSettingsOverview() {
),
)
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const optionalFeatures = computed(() => store.adminOptionalFeatureSettings)
const pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const healthLoadedLabel = computed(() =>
@@ -29,12 +30,14 @@ export function useAdminSettingsOverview() {
siteSettings.value.imprintUrl,
siteSettings.value.contactUrl,
siteSettings.value.sponsorsUrl,
siteSettings.value.showactsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const configuredFooterPages = computed(() => [
siteSettings.value.imprintContent,
siteSettings.value.contactContent,
siteSettings.value.sponsorsContent,
siteSettings.value.showactsContent,
].filter((content) => content.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
@@ -62,8 +65,8 @@ export function useAdminSettingsOverview() {
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
value: configuredFooterLinks.value === 5 && configuredFooterPages.value === 4,
note: `${configuredFooterLinks.value} von 5 Link-Zielen, ${configuredFooterPages.value} von 4 Footer-Seiten gepflegt`,
icon: Link2,
to: '/admin/content',
},
@@ -143,7 +146,15 @@ export function useAdminSettingsOverview() {
to: '/admin/categories',
},
{
label: 'Clip-Reviews offen',
label: 'Clip-Workflow',
state: optionalFeatures.value.clipSubmissionsEnabled,
note: optionalFeatures.value.clipSubmissionsEnabled
? 'Public-Clip-Einreichung ist aktiv.'
: 'Public-Clip-Einreichung ist deaktiviert.',
to: '/admin/settings',
},
{
label: 'Clip-Inbox',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
@@ -2,8 +2,16 @@ import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
const allFilter = 'all'
const maxWinnerPlacementsRuleKey = 'max_winner_placements'
const winnerRequiresClipRuleKey = 'winner_requires_clip'
type WinnerRuleNotice = {
mode: 'warn' | 'block'
message: string
}
export function useAdminWinnersManager() {
const store = useAwardsStore()
@@ -16,7 +24,53 @@ export function useAdminWinnersManager() {
const winnerSelections = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const selectedCandidateMap = computed(() => new Map(seasonDetail.value.candidates.map((candidate) => [candidate.id, candidate])))
function findWorkflowRule(ruleKey: string): AdminWorkflowRule | null {
return workflowRules.value.find((rule) => rule.key === ruleKey) ?? null
}
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function winnerRuleNoticeFor(categoryId: number): WinnerRuleNotice | null {
const candidateId = Number(winnerSelections[categoryId])
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
if (!selectedCandidate) return null
const clipRule = findWorkflowRule(winnerRequiresClipRuleKey)
if (clipRule?.enabled && !selectedCandidate.clipCompilationUrl?.trim()) {
const mode = clipRule.mode === 'warn' ? 'warn' : 'block'
return {
mode,
message: 'Dieser Kandidat hat noch keinen Clip-Link. Bitte in der Kandidatenvorbereitung eine YouTube-/Twitch-Compilation pflegen.',
}
}
const rule = findWorkflowRule(maxWinnerPlacementsRuleKey)
if (!rule?.enabled) return null
const identityKey = candidateIdentityKey(selectedCandidate)
const existingWinnerCount = seasonDetail.value.results.filter((result) => {
if (result.categoryId === categoryId) return false
return candidateIdentityKey({
displayName: result.candidateDisplayName,
channelSlug: result.candidateChannelSlug,
}) === identityKey
}).length
if (existingWinnerCount < rule.limit) return null
const mode = rule.mode === 'warn' ? 'warn' : 'block'
return {
mode,
message: `Diese Person hat bereits ${existingWinnerCount} Gewinnerplatz(e). Limit: ${rule.limit}.`,
}
}
const resultRows = computed(() =>
seasonDetail.value.categories
@@ -125,6 +179,13 @@ export function useAdminWinnersManager() {
async function saveWinner(categoryId: number) {
const candidateId = Number(winnerSelections[categoryId])
if (!candidateId || !seasonDetail.value.id) return
const ruleNotice = winnerRuleNoticeFor(categoryId)
if (ruleNotice?.mode === 'block') {
adminMessage.value = ''
adminError.value = `Workflow-Regel blockiert: ${ruleNotice.message}`
return
}
savingResultForCategory.value = categoryId
adminMessage.value = ''
adminError.value = ''
@@ -167,5 +228,6 @@ export function useAdminWinnersManager() {
winnerSelections,
clearWinner,
saveWinner,
winnerRuleNoticeFor,
}
}
@@ -0,0 +1,97 @@
import { computed, onMounted, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminWorkflowRule } from '../../types/awards'
function cloneRules(rules: AdminWorkflowRule[]) {
return rules.map((rule) => ({ ...rule }))
}
function normalizeRule(rule: AdminWorkflowRule): AdminWorkflowRule {
return {
...rule,
enabled: Boolean(rule.enabled),
limit: Math.min(50, Math.max(1, Number(rule.limit) || 1)),
mode: rule.mode === 'warn' ? 'warn' : 'block',
}
}
export function useAdminWorkflowRules() {
const store = useAwardsStore()
const workflowLoading = ref(false)
const workflowSaving = ref(false)
const workflowError = ref('')
const workflowSuccess = ref('')
const workflowRules = ref<AdminWorkflowRule[]>([])
const originalSnapshot = ref('')
const workflowRuleSummary = computed(() => {
const active = workflowRules.value.filter((rule) => rule.enabled).length
const blocking = workflowRules.value.filter((rule) => rule.enabled && rule.mode === 'block').length
return {
active,
blocking,
total: workflowRules.value.length,
}
})
const hasUnsavedWorkflowRuleChanges = computed(() =>
JSON.stringify(workflowRules.value.map(normalizeRule)) !== originalSnapshot.value,
)
async function loadWorkflowRules() {
workflowLoading.value = true
workflowError.value = ''
workflowSuccess.value = ''
try {
const response = await store.loadAdminWorkflowRules()
workflowRules.value = cloneRules(response.rules)
originalSnapshot.value = JSON.stringify(workflowRules.value.map(normalizeRule))
} catch (error) {
workflowError.value = error instanceof Error ? error.message : 'Workflow-Regeln konnten nicht geladen werden.'
workflowRules.value = []
originalSnapshot.value = '[]'
} finally {
workflowLoading.value = false
}
}
function updateWorkflowRule(ruleKey: string, patch: Partial<AdminWorkflowRule>) {
workflowRules.value = workflowRules.value.map((rule) =>
rule.key === ruleKey ? normalizeRule({ ...rule, ...patch }) : rule,
)
}
async function saveWorkflowRules() {
workflowSaving.value = true
workflowError.value = ''
workflowSuccess.value = ''
try {
const payloadRules = workflowRules.value.map(normalizeRule)
const response = await store.updateAdminWorkflowRules({ rules: payloadRules })
workflowRules.value = cloneRules(response.rules)
originalSnapshot.value = JSON.stringify(workflowRules.value.map(normalizeRule))
workflowSuccess.value = 'Workflow-Regeln wurden gespeichert.'
} catch (error) {
workflowError.value = error instanceof Error ? error.message : 'Workflow-Regeln konnten nicht gespeichert werden.'
} finally {
workflowSaving.value = false
}
}
onMounted(loadWorkflowRules)
return {
hasUnsavedWorkflowRuleChanges,
workflowError,
workflowLoading,
workflowRules,
workflowRuleSummary,
workflowSaving,
workflowSuccess,
loadWorkflowRules,
saveWorkflowRules,
updateWorkflowRule,
}
}
@@ -368,9 +368,9 @@
margin: -4px auto 12px;
padding: 6px 0 10px;
color: #fff8fd;
font-family: 'Cormorant Garamond', serif;
font-size: clamp(42px, 5.4vw, 66px);
line-height: 1.08;
font-family: 'Fredoka', sans-serif;
font-size: clamp(36px, 4.8vw, 58px);
line-height: 1.12;
font-weight: 700;
overflow: visible;
text-wrap: balance;
@@ -61,7 +61,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
<div class="flex shrink-0 items-center justify-between gap-4 border-b border-[#f1ecfb] px-8 py-5">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Rechtliches</p>
<h2 class="font-['Cormorant_Garamond'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
<h2 class="font-['Fredoka'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
</div>
<button
type="button"
@@ -101,7 +101,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
</div>
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Mein Profil</p>
<h2 class="truncate font-['Cormorant_Garamond'] text-2xl font-bold leading-tight text-[#3f3556]">
<h2 class="truncate font-['Fredoka'] text-2xl font-bold leading-tight text-[#3f3556]">
@{{ profileHandle }}
</h2>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
const props = defineProps<{
@@ -13,19 +14,25 @@ const props = defineProps<{
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
}>()
function initialsFor(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean)
const initials = parts.length > 1
? `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`
: value.slice(0, 2)
return initials.toUpperCase()
}
</script>
<template>
<template v-if="props.archiveModalOpen">
<div class="home-modal-overlay" @click="props.onCloseArchive" style="position:fixed;inset:0;z-index:260;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(34,18,58,.46);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);">
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:1px solid rgba(255,255,255,.62);">
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:none;">
<button @click="props.onCloseArchive" aria-label="Archiv schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);"></button>
<div style="position:relative;padding:30px 34px 22px;border-bottom:1px solid rgba(255,210,122,.14);background:linear-gradient(135deg,#2a1842,#3a2168);overflow:hidden;">
<span style="position:absolute;top:16px;left:24px;font-size:14px;color:#ffd27a;animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:26px;right:84px;font-size:12px;color:#ffb9d4;animation:twinkle 2.5s ease-in-out .4s infinite;"></span>
<span style="position:absolute;bottom:18px;left:280px;font-size:13px;color:#c9b1ff;animation:twinkle 3.2s ease-in-out .9s infinite;"></span>
<HomeStarField :count="13" variant="header" />
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#c9b1ff;margin-bottom:8px;position:relative;z-index:1;">Archiv</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:38px;line-height:1;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
</div>
<div class="home-archive-modal__body" style="display:grid;grid-template-columns:220px minmax(0,1fr);min-height:0;flex:1;">
<aside class="home-archive-modal__years" style="padding:24px 18px;border-right:1px solid rgba(139,108,219,.12);background:rgba(255,255,255,.34);overflow-y:auto;">
@@ -53,34 +60,41 @@ const props = defineProps<{
<article
v-for="winner in props.selectedArchive.winners"
:key="`${props.selectedArchive.year}-${winner.category}`"
style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:16px 18px;border-radius:18px;background:rgba(255,255,255,.7);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:15px;padding:16px 18px;border-radius:20px;background:rgba(255,255,255,.74);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
>
<div style="width:76px;height:76px;border-radius:22px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:24px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
{{ initialsFor(winner.name) }}
</div>
<div style="min-width:0;">
<div style="font-size:10px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#d9942a;margin-bottom:7px;">{{ winner.category }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:18px;line-height:1.25;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ winner.name }}</div>
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:9px;margin-top:12px;">
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
{{ props.winnerPlatformLabel(winner.url) }}
</a>
<a
v-if="winner.clipUrl"
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="display:inline-flex;align-items:center;gap:6px;color:#b7791f;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:800;font-size:12px;"
>
{{ winner.clipTitle }}
</a>
</div>
<iframe
v-if="winner.clipEmbedUrl"
:src="winner.clipEmbedUrl"
:title="winner.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;aspect-ratio:16/9;border:0;border-radius:14px;background:#1b0d2d;margin-top:12px;"
/>
</div>
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
<template v-if="props.winnerPlatformKey(winner.url) === 'twitch'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'youtube'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.5 15.5v-7l6.5 3.5z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'x'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'instagram'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'cake'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11h16"/><path d="M6 11V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v3"/><path d="M5 11h14l-1 7a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M9 6a2 2 0 1 1 4 0"/></svg>
</template>
<template v-else>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>
</template>
{{ props.winnerPlatformLabel(winner.url) }}
</a>
</article>
</div>
</div>
@@ -2,7 +2,7 @@
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="text-align:center;margin-bottom:52px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#ff5fa2);margin-bottom:12px;"> Die Awards</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Newcomer bis VTuber des Jahres für jede Art von Magie gibt es einen Stern zu gewinnen.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
@@ -0,0 +1,37 @@
<script setup lang="ts">
export interface HomeCategoryProgressRailItem {
id: string | number
name: string
icon: string
status: string
tone: 'empty' | 'active' | 'done' | 'error'
}
const props = defineProps<{
items: HomeCategoryProgressRailItem[]
activeId: string | number
label: string
onSelect: (id: string | number) => void
}>()
</script>
<template>
<aside class="home-wizard-rail">
<div class="home-wizard-rail__label">{{ props.label }}</div>
<button
v-for="item in props.items"
:key="item.id"
type="button"
class="home-wizard-rail__button"
:class="[
`home-wizard-rail__button--${item.tone}`,
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
]"
@click="props.onSelect(item.id)"
>
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
<span class="home-wizard-rail__name">{{ item.name }}</span>
<span class="home-wizard-rail__status">{{ item.status }}</span>
</button>
</aside>
</template>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { CheckCircle2, Handshake, Mic2, Send } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
const form = reactive({
artistName: '',
contactEmail: '',
contactDiscord: '',
platformUrl: '',
performanceType: '',
description: '',
technicalNotes: '',
referenceUrl: '',
})
const featureFlags = computed(() => store.overview.featureFlags)
const sponsors = computed(() =>
featureFlags.value.sponsorsVisible ? store.publicSponsors.items.filter((item) => item.isVisible) : [],
)
const showSection = computed(() => sponsors.value.length > 0 || featureFlags.value.showactApplicationsEnabled)
function resetForm() {
form.artistName = ''
form.contactEmail = ''
form.contactDiscord = ''
form.platformUrl = ''
form.performanceType = ''
form.description = ''
form.technicalNotes = ''
form.referenceUrl = ''
}
async function submitShowact() {
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
await store.submitShowactApplication(form)
submitMessage.value = 'Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.'
resetForm()
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.'
} finally {
submitting.value = false
}
}
</script>
<template>
<section v-if="showSection" class="mx-auto grid w-full max-w-6xl gap-5 px-5 py-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
<div v-if="sponsors.length > 0" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
<div class="flex items-center gap-3">
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-sky-100 text-sky-700">
<Handshake class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-bold uppercase tracking-[0.2em] text-sky-600">Partner</p>
<h2 class="text-xl font-black text-[#3f3556]">Sponsoren</h2>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<a
v-for="sponsor in sponsors"
:key="sponsor.id"
:href="sponsor.websiteUrl || undefined"
:target="sponsor.websiteUrl ? '_blank' : undefined"
rel="noreferrer"
class="group min-h-[136px] rounded-[20px] border border-violet-100 bg-white p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_36px_rgba(105,78,160,0.12)]"
>
<div class="flex items-start gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-2xl border border-violet-100 bg-violet-50 text-sm font-black text-violet-700">
<img v-if="sponsor.logoUrl" :src="sponsor.logoUrl" :alt="sponsor.name" class="h-full w-full object-contain p-1" loading="lazy" />
<span v-else>{{ sponsor.name.slice(0, 2).toUpperCase() }}</span>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-black text-[#3f3556]">{{ sponsor.name }}</p>
<p class="mt-1 text-xs font-bold uppercase tracking-[0.14em] text-sky-600">{{ sponsor.tier }}</p>
</div>
</div>
<p v-if="sponsor.description" class="mt-3 line-clamp-3 text-sm leading-6 text-[#746b86]">{{ sponsor.description }}</p>
</a>
</div>
</div>
<div v-if="featureFlags.showactApplicationsEnabled" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
<div class="flex items-center gap-3">
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-fuchsia-100 text-fuchsia-700">
<Mic2 class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-bold uppercase tracking-[0.2em] text-fuchsia-600">Showacts</p>
<h2 class="text-xl font-black text-[#3f3556]">Bewerben</h2>
</div>
</div>
<form class="mt-5 space-y-3" @submit.prevent="submitShowact">
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.artistName" required maxlength="120" placeholder="Kuenstlername" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.performanceType" required maxlength="80" placeholder="Showact-Art" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.contactEmail" maxlength="180" placeholder="E-Mail" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.contactDiscord" maxlength="120" placeholder="Discord" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.platformUrl" maxlength="500" placeholder="Kanal/Profil URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.referenceUrl" maxlength="500" placeholder="Referenz URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<textarea v-model="form.description" required rows="3" maxlength="1000" placeholder="Kurz beschreiben, was du zeigen moechtest" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<textarea v-model="form.technicalNotes" rows="2" maxlength="1000" placeholder="Technische Hinweise, Setup, Timing" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<p v-if="submitError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ submitError }}</p>
<p v-if="submitMessage" class="flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
<CheckCircle2 class="h-4 w-4" />
{{ submitMessage }}
</p>
<button
type="submit"
:disabled="submitting"
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-fuchsia-500 bg-fuchsia-600 px-5 text-sm font-black text-white shadow-[0_14px_28px_rgba(192,38,211,0.2)] transition hover:bg-fuchsia-500 disabled:opacity-60"
>
<Send class="h-4 w-4" />
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
</button>
</form>
</div>
</section>
</template>
@@ -1,8 +1,6 @@
<template>
<section class="home-stream-band" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);">
<span style="position:absolute;top:18px;left:7%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;bottom:16px;left:46%;font-size:12px;color:rgba(255,255,255,.3);animation:twinkle 2.6s ease-in-out .5s infinite;"></span>
<span style="position:absolute;top:24px;right:38%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;"></span>
<HomeStarField :count="13" variant="light" />
<div class="home-stream-band__inner" style="max-width:1200px;margin:0 auto;padding:26px 24px;display:flex;align-items:center;justify-content:space-between;gap:26px;flex-wrap:wrap;">
<div class="home-stream-band__lead" style="display:flex;align-items:center;gap:18px;">
<div style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:54px;height:54px;border-radius:15px;background:rgba(145,70,255,.22);border:1px solid rgba(255,255,255,.18);">
@@ -10,7 +8,7 @@
</div>
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:5px;">{{ streamEyebrow }}</div>
<div style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;font-size:14px;color:rgba(255,255,255,.78);margin-top:6px;">
<span style="display:inline-flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9b1ff" stroke-width="2"><rect x="3" y="4.5" width="18" height="17" rx="2.5"/><path d="M3 9h18M8 2.5v4M16 2.5v4" stroke-linecap="round"/></svg>{{ streamMeta }}</span>
<span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,.4);"></span>
@@ -46,6 +44,8 @@
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
defineProps<{
publicStreamUrl: string
streamEyebrow: string
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import HomeCategoryProgressRail from './HomeCategoryProgressRail.vue'
import HomeNominationReviewPane from './HomeNominationReviewPane.vue'
import HomeNominationWizardPane from './HomeNominationWizardPane.vue'
import HomeSelectDropdown from './HomeSelectDropdown.vue'
import HomeStarField from './HomeStarField.vue'
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
import HomeWizardFooter from './HomeWizardFooter.vue'
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
const props = defineProps<{
modalOpen: boolean
@@ -30,8 +36,10 @@ const props = defineProps<{
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
activeCatIndex: number
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
@@ -47,10 +55,14 @@ const props = defineProps<{
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomQuery = ref('')
const nominationDrafts = ref<Record<number, string[]>>({})
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
const nominationReviewMode = ref(false)
watch(
() => props.catOptions,
(options) => {
syncNominationDrafts(options)
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
if (nextClipCat !== clipCatValue.value) {
@@ -61,6 +73,16 @@ watch(
{ immediate: true },
)
watch(
() => props.modalOpen,
(isOpen) => {
if (!isOpen) {
nominationReviewMode.value = false
nominationCatValue.value = 0
}
},
)
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
}
@@ -74,20 +96,213 @@ function handleClipCategoryChange(value: number) {
function notifyClipCategoryChange(value: number) {
props.onClipCatChange({ target: { value: String(value) } } as unknown as Event)
}
function syncNominationDrafts(options: HomeSelectionOption[]) {
const nextDrafts: Record<number, string[]> = {}
const nextVisibleLinkCounts: Record<number, number> = {}
for (const option of options) {
const existing = nominationDrafts.value[option.id] ?? []
nextDrafts[option.id] = [existing[0] ?? '', existing[1] ?? '', existing[2] ?? '']
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
nextVisibleLinkCounts[option.id] = Math.min(3, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
}
nominationDrafts.value = nextDrafts
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
}
function nominationLinksFor(categoryIndex: number) {
return nominationDrafts.value[categoryIndex] ?? ['', '', '']
}
function nominationVisibleLinkCountFor(categoryIndex: number) {
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
}
function compactNominationCategory(categoryIndex: number) {
const links = nominationLinksFor(categoryIndex)
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, 3)
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
const nextVisibleCount = Math.max(1, compactedLinks.length)
nominationDrafts.value = {
...nominationDrafts.value,
[categoryIndex]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[categoryIndex]: nextVisibleCount,
}
}
function onNominationLinkInput(index: number, value: string) {
const links = [...nominationLinksFor(nominationCatValue.value)]
links[index] = value
nominationDrafts.value = {
...nominationDrafts.value,
[nominationCatValue.value]: links,
}
}
function nominationFieldErrors(categoryIndex: number) {
const links = nominationLinksFor(categoryIndex)
return links.map((link, index) => {
const value = link.trim()
if (!value) return ''
if (!isHttpUrl(value)) return 'Bitte gib einen gueltigen http(s)-Link ein.'
const normalized = normalizeUrlForCompare(value)
const duplicateIndex = links.findIndex((candidate, candidateIndex) =>
candidateIndex !== index && normalizeUrlForCompare(candidate) === normalized,
)
return duplicateIndex >= 0 ? 'Dieser Link ist in dieser Kategorie schon eingetragen.' : ''
})
}
const activeNominationCategory = computed(() =>
props.catOptions.find((option) => option.id === nominationCatValue.value) ?? props.catOptions[0] ?? null,
)
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
const activeNominationRemainingLinks = computed(() => Math.max(0, 3 - activeNominationVisibleLinkCount.value))
const nominationReviewItems = computed(() =>
props.catOptions
.map((option) => ({
categoryIndex: option.id,
categoryName: stripCategoryIcon(option.label),
links: nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean),
}))
.filter((item) => item.links.length > 0),
)
const nominationTotalLinks = computed(() =>
nominationReviewItems.value.reduce((sum, item) => sum + item.links.length, 0),
)
const nominationHasErrors = computed(() =>
props.catOptions.some((option) => nominationFieldErrors(option.id).some(Boolean)),
)
const nominationRailItems = computed(() => props.catOptions.map((option) => {
const errors = nominationFieldErrors(option.id)
const linkCount = nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean).length
const isActive = option.id === nominationCatValue.value
return {
id: option.id,
name: stripCategoryIcon(option.label),
icon: option.label.trim().split(' ')[0] ?? '✦',
status: errors.some(Boolean) ? 'Fehler' : linkCount === 0 ? 'Leer' : `${linkCount} Link${linkCount === 1 ? '' : 's'}`,
tone: errors.some(Boolean) ? 'error' as const : linkCount > 0 ? 'done' as const : isActive ? 'active' as const : 'empty' as const,
}
}))
const nominationProgressText = computed(() =>
`${nominationReviewItems.value.length} / ${props.catOptions.length} Kategorien ausgefuellt`,
)
const nominationHelperText = computed(() =>
nominationTotalLinks.value > 0
? `${nominationTotalLinks.value} Link${nominationTotalLinks.value === 1 ? '' : 's'} bereit. Leere Kategorien werden uebersprungen.`
: 'Leere Kategorien kannst du einfach ueberspringen.',
)
const isLastNominationCategory = computed(() => {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
return activeIndex >= props.catOptions.length - 1
})
function selectNominationCategory(id: string | number) {
compactNominationCategory(nominationCatValue.value)
nominationCatValue.value = Number(id)
nominationReviewMode.value = false
}
function goToPreviousNominationCategory() {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
const previous = props.catOptions[Math.max(0, activeIndex - 1)]
if (previous) selectNominationCategory(previous.id)
}
function goToNextNominationCategory() {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
const next = props.catOptions[Math.min(props.catOptions.length - 1, activeIndex + 1)]
if (next) selectNominationCategory(next.id)
}
function openNominationReview() {
compactNominationCategory(nominationCatValue.value)
nominationReviewMode.value = true
}
function closeNominationReview() {
nominationReviewMode.value = false
}
function editNominationCategory(categoryIndex: number) {
nominationCatValue.value = categoryIndex
nominationReviewMode.value = false
}
function addNominationLinkField() {
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: Math.min(3, currentCount + 1),
}
}
function removeNominationLinkField(index: number) {
if (index <= 0) return
const compactedLinks = nominationLinksFor(nominationCatValue.value)
.filter((_, linkIndex) => linkIndex !== index)
.map((link) => link.trim())
.filter(Boolean)
.slice(0, 3)
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
const nextVisibleCount = Math.max(1, Math.min(3, Math.max(previousVisibleCount - 1, compactedLinks.length)))
nominationDrafts.value = {
...nominationDrafts.value,
[nominationCatValue.value]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: nextVisibleCount,
}
}
async function submitNominationDrafts() {
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
await props.submitNomination({
entries: nominationReviewItems.value.map((item) => ({
categoryIndex: item.categoryIndex,
streamUrls: item.links,
})),
})
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeUrlForCompare(value: string) {
return value.trim().replace(/\/+$/, '').toLowerCase()
}
function stripCategoryIcon(value: string) {
return value.replace(/^\S+\s+/, '')
}
</script>
<template>
<template v-if="props.modalOpen">
<div class="home-modal-overlay" @click="props.closeModal" style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker && (props.isVote || (props.nominationPhase && !props.isVote)) }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:16px;right:16px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;" style-hover="background:#e6dcf6;"></button>
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.isVote && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);"></button>
<template v-if="props.submitted">
<div class="home-modal__success" style="padding:56px 40px;text-align:center;">
<div style="width:72px;height:72px;margin:0 auto 22px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 14px 32px rgba(31,157,90,.32);">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:28px;line-height:1.14;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
<p style="font-size:16px;line-height:1.6;color:#7d7491;max-width:420px;margin:0 auto 28px;">{{ props.successText }}</p>
<button @click="props.closeModal" style="padding:14px 32px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Schliessen</button>
</div>
@@ -104,7 +319,7 @@ function notifyClipCategoryChange(value: number) {
<template v-if="props.isShow">
<div class="home-modal__show" style="padding:38px 40px 40px;">
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:28px;line-height:1.14;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<p style="font-size:15.5px;line-height:1.6;color:#7d7491;margin:0 0 24px;">Die grosse Live-Show findet am <strong style="color:#5f44ad;">{{ props.formatShowDate() }}</strong> statt. Aktiviere eine Erinnerung, damit du nichts verpasst.</p>
<div class="home-modal__reminder-form" style="display:flex;gap:10px;margin-bottom:14px;">
<input data-dc-ref="emailRef" type="email" placeholder="deine@email.de" style="flex:1;padding:14px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;" style-focus="border-color:#8b6cdb;" />
@@ -116,104 +331,80 @@ function notifyClipCategoryChange(value: number) {
<template v-if="props.isPicker">
<template v-if="props.nominationPhase && !props.isVote">
<div class="home-modal__nomination-submit" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;"> Nominierungsphase</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
<div class="home-modal__nomination-submit home-wizard-shell">
<div class="home-modal__picker-header" style="padding:30px 36px 22px;border-bottom:1px solid #f1ecfb;flex:none;">
<HomeStarField :count="13" variant="header" />
<div style="display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;"> Nominierungsphase</div>
<div class="home-wizard-header-row">
<div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">Streamer nominieren</h3>
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.</p>
</div>
</div>
</div>
<div class="home-modal__nomination-grid" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:24px 36px 32px;overflow-y:auto;">
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Der Stream-Link geht direkt in den Admin-Review; den Anzeigenamen vergibt das Team.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
<HomeSelectDropdown
v-model="nominationCatValue"
data-ref="nominationCatRef"
label="Kategorie fuer Nominierung auswaehlen"
:options="props.catOptions"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://plattform.de/dein-kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Offizieller Kanal- oder Stream-Link der Person.</p>
</div>
<button @click="props.submitNomination" :disabled="props.submitting" :style="props.submitting ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);'" style-hover="transform:translateY(-2px);">
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
</button>
</section>
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fffafd;border:1px solid #f4d8e9;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">Clip einreichen</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Highlight-Clips können separat zur Show-Prüfung eingereicht werden.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
</div>
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
<HomeSelectDropdown
v-model="clipCatValue"
label="Kategorie fuer Clip auswaehlen"
:options="props.catOptions"
@change="handleClipCategoryChange"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
style-focus="border-color:#8b6cdb;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Beschreibung <span style="color:#a99fc0;font-weight:400;">(optional)</span></label>
<textarea data-dc-ref="clipDescRef" placeholder="Warum ist dieser Moment so besonders?" rows="3" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;resize:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;"></textarea>
</div>
<label style="display:flex;align-items:flex-start;gap:11px;cursor:pointer;padding:13px;border-radius:11px;background:#fff;border:1px solid #ede4fb;">
<input type="checkbox" :checked="props.clipDsgvo" @change="props.clipDsgvoChange" style="width:17px;height:17px;flex:none;margin-top:2px;accent-color:#8b6cdb;cursor:pointer;" />
<span style="font-size:13px;line-height:1.6;color:#6f6685;">Ich stimme der Verarbeitung meiner Daten gemäß der <button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:#8b6cdb;font-weight:600;cursor:pointer;font-size:inherit;font-family:inherit;">Datenschutzerklärung</button> zu.</span>
</label>
<button @click="props.submitClip" :disabled="props.submitting || !props.canSubmitClip" :style="props.clipSubmitStyle">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
{{ props.submitting ? 'Speichert ...' : 'Clip einreichen' }}
</button>
</section>
<div class="home-wizard-layout">
<HomeCategoryProgressRail
label="Kategorien"
:items="nominationRailItems"
:active-id="nominationCatValue"
:on-select="selectNominationCategory"
/>
<main class="home-wizard-content">
<HomeNominationReviewPane
v-if="nominationReviewMode"
:items="nominationReviewItems"
:total-links="nominationTotalLinks"
:total-categories="props.catOptions.length"
:on-edit-category="editNominationCategory"
/>
<HomeNominationWizardPane
v-else
:category-name="activeNominationCategory ? stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
:links="activeNominationLinks"
:errors="activeNominationErrors"
:visible-link-count="activeNominationVisibleLinkCount"
:remaining-link-count="activeNominationRemainingLinks"
:on-link-input="onNominationLinkInput"
:on-add-link-field="addNominationLinkField"
:on-remove-link-field="removeNominationLinkField"
/>
</main>
</div>
<HomeWizardFooter
:progress-text="nominationProgressText"
:helper-text="nominationHelperText"
:actions="nominationReviewMode
? [
{ label: 'Zurueck', tone: 'secondary', onClick: closeNominationReview },
{ label: props.submitting ? 'Speichert ...' : 'Nominierungen einreichen', disabled: props.submitting || nominationHasErrors || nominationTotalLinks === 0, onClick: submitNominationDrafts },
]
: [
{ label: 'Zurueck', tone: 'secondary', disabled: nominationCatValue === props.catOptions[0]?.id, onClick: goToPreviousNominationCategory },
{ label: isLastNominationCategory ? 'Nominierungen pruefen' : 'Weiter', disabled: nominationHasErrors, onClick: isLastNominationCategory ? openNominationReview : goToNextNominationCategory },
]"
/>
</div>
</template>
<template v-else>
<div style="display:flex;flex-direction:column;min-height:0;">
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;">
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:27px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
<div class="home-vote-shell">
<div class="home-modal__picker-header" style="padding:30px 36px 22px;border-bottom:1px solid #f1ecfb;">
<HomeStarField :count="13" variant="header" />
<div style="position:relative;z-index:1;display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;">{{ props.isVote ? '★ Votingphase' : '✦ Nominierungen' }}</div>
<h3 style="position:relative;z-index:1;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="position:relative;z-index:1;font-size:16px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
</div>
<HomeVotingPickerPane
:cat-list="props.catList"
:active-cat-index="props.activeCatIndex"
:active-cat-name="props.activeCatName"
:noms="props.noms"
:vote-count="props.voteCount"
:total-cats="props.totalCats"
:can-submit-vote="props.canSubmitVote"
:saved-vote-active="props.savedVoteActive"
:submit-vote="props.submitVote"
:submitting="props.submitting"
:readonly-mode="!props.isVote"
/>
</div>
</template>
@@ -223,7 +414,7 @@ function notifyClipCategoryChange(value: number) {
<div class="home-modal__clip" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
<div class="home-modal__clip-header" style="padding:30px 40px 24px;border-bottom:1px solid #f1ecfb;flex:none;">
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;"> Nominierungsphase</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;line-height:1.14;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">Die besten Clips werden in der Award-Show präsentiert.</p>
</div>
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
@@ -3,8 +3,10 @@ import { useRouter } from 'vue-router'
import CinematicStarLoader from '../CinematicStarLoader.vue'
import HomeCategoriesSection from './HomeCategoriesSection.vue'
import HomeHeroShell from './HomeHeroShell.vue'
import HomeExtrasSection from './HomeExtrasSection.vue'
import HomeLandingModals from './HomeLandingModals.vue'
import HomeParticipationSection from './HomeParticipationSection.vue'
import HomeStickyCountdownPill from './HomeStickyCountdownPill.vue'
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
import HomeTimelineSection from './HomeTimelineSection.vue'
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
@@ -50,6 +52,7 @@ const {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCatName,
phaseCardTitle,
phaseCardDescription,
@@ -89,6 +92,7 @@ const {
archiveYears,
selectedArchive,
winnerShowcase,
activeCat,
submitted,
submitting,
formError,
@@ -143,13 +147,13 @@ const {
} = useHomeLandingState()
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'nomination', label: 'Nominierung', hint: 'Links einreichen' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({
const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmit } = useHomeLandingViewEffects({
router,
store,
authStore,
@@ -165,7 +169,6 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
})
</script>
@@ -186,6 +189,13 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
</transition>
<div :style="rootStyle" class="home-landing">
<HomeStickyCountdownPill
:show-countdown="showCountdown"
:visible="stickyCountdownVisible"
:show-phase="showPhase"
:public-stream-url="publicStreamUrl"
/>
<div v-if="isAdmin" class="home-demo-preview" aria-label="Demo Phasen-Vorschau">
<div class="home-demo-preview__label">
<span></span>
@@ -282,6 +292,8 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:on-section-action="onSectionAction"
/>
<HomeExtrasSection />
<HomeSupportFooterSection
:community-social-links="communitySocialLinks"
:site-content="siteContent"
@@ -314,13 +326,15 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-index="activeCat"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:saved-vote-active="savedVoteActive"
:submit-vote="submitVote"
:submit-nomination="handleNominationSubmit"
:submit-nomination="submitNomination"
:submitting="submitting"
:on-clip-cat-change="onClipCatChange"
:cat-options="catOptions"
@@ -9,6 +9,7 @@ import type {
HomeSelectedArchive,
HomeSelectionOption,
} from './homeModalTypes'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
import type { AuthSession } from '../../types/awards'
defineProps<{
@@ -31,13 +32,15 @@ defineProps<{
pickerTitle: string
pickerSubtitle: string
catList: HomeCategoryListItem[]
activeCatIndex: number
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
@@ -100,11 +103,13 @@ defineProps<{
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-index="activeCatIndex"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:saved-vote-active="savedVoteActive"
:submit-vote="submitVote"
:submit-nomination="submitNomination"
:submitting="submitting"
@@ -0,0 +1,37 @@
<script setup lang="ts">
const props = defineProps<{
missingCategories: string[]
onBack: () => void
onConfirm: () => Promise<void> | void
submitting: boolean
submitLabel: string
hideActions?: boolean
}>()
</script>
<template>
<section class="home-missing-votes">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nicht alle Kategorien gewaehlt</h4>
<p>
In {{ props.missingCategories.length }} Kategorien fehlt noch eine Stimme. Du kannst trotzdem speichern
oder zurueckgehen und weitere Kategorien auswaehlen.
</p>
<div class="home-missing-votes__chips">
<span v-for="category in props.missingCategories" :key="category">{{ category }}</span>
</div>
<div v-if="!props.hideActions" class="home-missing-votes__actions">
<button type="button" class="home-wizard-footer__button home-wizard-footer__button--secondary" @click="props.onBack">
Zurueck zum Voting
</button>
<button
type="button"
class="home-wizard-footer__button home-wizard-footer__button--primary"
:disabled="props.submitting"
@click="props.onConfirm"
>
{{ props.submitting ? 'Speichert ...' : props.submitLabel }}
</button>
</div>
</section>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
export interface HomeNominationReviewItem {
categoryIndex: number
categoryName: string
links: string[]
}
const props = defineProps<{
items: HomeNominationReviewItem[]
totalLinks: number
totalCategories: number
onEditCategory: (index: number) => void
}>()
</script>
<template>
<section class="home-nomination-review">
<div class="home-nomination-review__hero">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nominierungen einreichen?</h4>
<p>
Du reichst {{ props.totalLinks }} Links in {{ props.items.length }} von
{{ props.totalCategories }} Kategorien ein. Leere Kategorien werden uebersprungen.
</p>
</div>
<div v-if="props.items.length" class="home-nomination-review__list">
<article v-for="item in props.items" :key="item.categoryName" class="home-nomination-review__item">
<div>
<h5>{{ item.categoryName }}</h5>
<span>{{ item.links.length }} Link{{ item.links.length === 1 ? '' : 's' }}</span>
</div>
<ul>
<li v-for="link in item.links" :key="link">{{ link }}</li>
</ul>
<button type="button" @click="props.onEditCategory(item.categoryIndex)">
Bearbeiten
</button>
</article>
</div>
<div v-else class="home-vote-picker__empty">
Noch keine Links eingetragen.
</div>
</section>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
const props = defineProps<{
categoryName: string
links: string[]
errors: string[]
visibleLinkCount: number
remainingLinkCount: number
onLinkInput: (index: number, value: string) => void
onAddLinkField: () => void
onRemoveLinkField: (index: number) => void
}>()
</script>
<template>
<section class="home-nomination-pane">
<header class="home-vote-picker__category-header">
<div>
<h4>{{ props.categoryName }}</h4>
</div>
</header>
<div class="home-nomination-pane__fields">
<label v-for="(_, index) in props.links.slice(0, props.visibleLinkCount)" :key="index" class="home-nomination-field">
<span class="home-nomination-field__header">
<span>{{ index === 0 ? 'Link 1' : `Link ${index + 1} optional` }}</span>
<button
v-if="index > 0"
type="button"
class="home-nomination-field__remove"
@click.prevent="props.onRemoveLinkField(index)"
>
Entfernen
</button>
</span>
<input
:value="props.links[index]"
type="url"
maxlength="300"
placeholder="https://plattform.de/dein-kanal"
:aria-invalid="Boolean(props.errors[index])"
@input="props.onLinkInput(index, ($event.target as HTMLInputElement).value)"
/>
<small v-if="props.errors[index]" class="home-nomination-field__error">{{ props.errors[index] }}</small>
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
</label>
</div>
<button
v-if="props.remainingLinkCount > 0"
type="button"
class="home-nomination-pane__add-link"
@click="props.onAddLinkField"
>
<span>+ Weiteren Link hinzufuegen</span>
<small>Noch {{ props.remainingLinkCount }} moeglich</small>
</button>
</section>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
const props = defineProps<{
sectionTitle: string
sectionText: string
@@ -27,7 +29,7 @@ const props = defineProps<{
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">1</span>
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Nominieren</h3>
</div>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie pro Kategorie 3 Nominierungen. Du kannst auch Clips deiner Lieblingsmomente einsenden.</p>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie pro Kategorie bis zu 3 Stream- oder Kanal-Links.</p>
</div>
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
<div style="flex:1;">
@@ -51,10 +53,8 @@ const props = defineProps<{
</div>
<div class="home-cta-card" style="position:relative;overflow:hidden;border-radius:30px;padding:56px 40px;text-align:center;background:linear-gradient(135deg,#8b6cdb,#b78bff);box-shadow:0 30px 70px rgba(124,86,196,.4);">
<span style="position:absolute;top:24px;left:8%;font-size:22px;color:#fff;opacity:.6;animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;bottom:30px;right:12%;font-size:18px;color:#fff;opacity:.6;animation:twinkle 2.6s ease-in-out .5s infinite;"></span>
<span style="position:absolute;top:40%;right:6%;font-size:14px;color:#fff;opacity:.5;animation:twinkle 3.2s ease-in-out 1s infinite;"></span>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
<HomeStarField :count="13" variant="light" />
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
<p style="font-size:18px;color:rgba(255,255,255,.92);max-width:540px;margin:0 auto 28px;">{{ props.sectionText }}</p>
<button
v-if="props.sectionActionDisabled"
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
type StarVariant = 'pastel' | 'light' | 'header'
const props = withDefaults(defineProps<{
count?: number
variant?: StarVariant
}>(), {
count: 12,
variant: 'light',
})
const palettes: Record<StarVariant, string[]> = {
pastel: ['#e7b13e', '#b79be8', '#f3a9cb', '#cdb6f0', '#ffb9d4'],
light: ['rgba(255,255,255,.62)', 'rgba(255,246,251,.54)', 'rgba(255,210,122,.5)', 'rgba(201,177,255,.58)'],
header: ['#ffd27a', '#ffb9d4', '#c9b1ff', '#fff6fb'],
}
function createStarPosition() {
return {
'--star-x': `${Math.round(4 + Math.random() * 92)}%`,
'--star-y': `${Math.round(8 + Math.random() * 84)}%`,
'--star-size': `${9 + Math.round(Math.random() * 12)}px`,
'--star-rotate': `${Math.round(Math.random() * 80 - 40)}deg`,
}
}
function createStarStyle(index: number) {
const palette = palettes[props.variant]
const alpha = props.variant === 'pastel' ? 0.85 : 0.62
return {
...createStarPosition(),
'--star-color': palette[index % palette.length],
'--star-alpha': String(alpha),
'--star-delay': `${(Math.random() * 8).toFixed(2)}s`,
'--star-duration': `${(8 + Math.random() * 5).toFixed(2)}s`,
}
}
const stars = ref(Array.from({ length: props.count }, (_, index) => {
const symbol = index % 3 === 1 ? '✧' : '✦'
return {
id: index,
symbol,
style: createStarStyle(index),
}
}))
function shuffleStar(index: number) {
stars.value[index].style = {
...stars.value[index].style,
...createStarPosition(),
}
}
</script>
<template>
<span class="home-star-field" aria-hidden="true">
<span
v-for="star in stars"
:key="star.id"
class="home-star-field__star"
:style="star.style"
@animationiteration="shuffleStar(star.id)"
>
{{ star.symbol }}
</span>
</span>
</template>
@@ -0,0 +1,37 @@
<template>
<div
v-if="showCountdown"
class="home-sticky-countdown"
:class="{ 'home-sticky-countdown--visible': visible }"
aria-live="polite"
>
<div class="home-sticky-countdown__inner">
<span class="home-sticky-countdown__dot" aria-hidden="true"></span>
<span data-dc-ref="stickyCountdownLabelRef" class="home-sticky-countdown__label">Finale startet in</span>
<span class="home-sticky-countdown__time" aria-label="Countdown bis zum Finale">
<span data-dc-ref="sbdRef">00</span><span class="home-sticky-countdown__unit">T</span>
<span data-dc-ref="sbhRef">00</span><span class="home-sticky-countdown__sep">:</span>
<span data-dc-ref="sbmRef">00</span><span class="home-sticky-countdown__sep">:</span>
<span data-dc-ref="sbsRef">00</span>
</span>
<a
v-if="showPhase"
:href="publicStreamUrl"
target="_blank"
rel="noopener noreferrer"
class="home-sticky-countdown__cta"
>
Zur Show
</a>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
showCountdown: boolean
visible: boolean
showPhase: boolean
publicStreamUrl: string
}>()
</script>
@@ -78,7 +78,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<div style="position:relative;display:grid;grid-template-columns:1.62fr 1fr;gap:26px;align-items:stretch;" data-community-grid>
<div class="home-community-card" style="position:relative;overflow:visible;background:#f5f0fc;border:1px solid #e9e0f8;border-radius:28px;padding:46px 46px 42px;min-height:360px;">
<div class="home-community-card__content" style="position:relative;z-index:2;max-width:62%;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 26px;">Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!</p>
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:26px;">
<a
@@ -117,7 +117,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<img class="home-community-card__image" src="/assets/jayu-hero.png" alt="Jayuhime" style="position:absolute;z-index:1;right:-26px;bottom:0;height:430px;width:auto;pointer-events:none;filter:drop-shadow(0 18px 36px rgba(120,80,180,.22));" />
</div>
<div class="home-share-card" style="background:linear-gradient(160deg,#fdf6f6,#faf3fb);border:1px solid #f0e7f2;border-radius:28px;padding:46px 38px;display:flex;flex-direction:column;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
@@ -132,7 +132,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<section id="faq" class="home-section" style="max-width:880px;margin:0 auto;padding:90px 24px;">
<div style="text-align:center;margin-bottom:46px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Häufige Fragen</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:var(--ink,#3f3556);">FAQ</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;color:var(--ink,#3f3556);">FAQ</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:520px;margin:0 auto;">Alles, was du über Nominierung, Voting und die Show wissen musst.</p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
@@ -184,7 +184,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
</div>
<button @click="closeFooterLink" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;"></button>
</div>
@@ -2,7 +2,7 @@
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
<div style="text-align:center;margin-bottom:56px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Der Ablauf</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur grossen Show.</p>
</div>
@@ -23,7 +23,7 @@
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Nominierung</h3>
<div style="font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;">{{ formatTimelineRange('nomination') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein pro Kategorie bis zu drei Nominierungen.</p>
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren und Clips einsenden' : 'Nominierungen ansehen' }}</button>
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren' : 'Nominierungen ansehen' }}</button>
</div>
</div>
@@ -1,16 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import HomeMissingVotesConfirm from './HomeMissingVotesConfirm.vue'
import HomeWizardFooter from './HomeWizardFooter.vue'
import type { HomeCategoryListItem, HomeNomineeListItem } from './homeModalTypes'
const props = defineProps<{
catList: HomeCategoryListItem[]
activeCatIndex: number
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
submitVote: () => Promise<void> | void
submitting: boolean
readonlyMode?: boolean
}>()
const confirmMissingVotes = ref(false)
const contentRef = ref<HTMLElement | null>(null)
const activeCategoryRef = ref<HTMLElement | null>(null)
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.name))
const submitLabel = computed(() => props.savedVoteActive ? 'Änderungen speichern' : 'Stimmen absenden')
const isLastCategory = computed(() => props.activeCatIndex >= props.catList.length - 1)
const footerPrimaryLabel = computed(() => {
if (!isLastCategory.value) return 'Weiter'
if (!props.canSubmitVote) return 'Erst Favorit:in wählen'
return 'Stimmen prüfen'
})
const footerHelperText = computed(() =>
confirmMissingVotes.value
? 'Pruefe die offenen Kategorien oder speichere deine aktuelle Auswahl.'
: props.readonlyMode
? 'Hier siehst du die freigegebenen Kandidat:innen der abgeschlossenen Nominierungsphase.'
: props.savedVoteActive
? 'Du bearbeitest dein gespeichertes Voting. Speichern ersetzt deine bisherige Auswahl.'
: 'Du kannst Kategorien ueberspringen und vor dem Speichern pruefen.',
)
const footerActions = computed(() =>
props.readonlyMode
? [
{
label: 'Zurueck',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
},
{
label: 'Weiter',
disabled: props.activeCatIndex >= props.catList.length - 1,
onClick: () => props.catList[Math.min(props.catList.length - 1, props.activeCatIndex + 1)]?.onClick(),
},
]
:
confirmMissingVotes.value
? [
{ label: 'Zurueck zum Voting', tone: 'secondary' as const, disabled: props.submitting, onClick: hideMissingVoteConfirm },
{ label: props.submitting ? 'Speichert ...' : submitLabel.value, disabled: props.submitting, onClick: submitConfirmedVote },
]
: [
{
label: 'Zurueck',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
},
{
label: props.submitting ? 'Speichert ...' : footerPrimaryLabel.value,
disabled: props.submitting || (isLastCategory.value && !props.canSubmitVote),
onClick: goToNextCategory,
},
],
)
function hideMissingVoteConfirm() {
confirmMissingVotes.value = false
}
watch(
() => props.activeCatIndex,
() => {
confirmMissingVotes.value = false
contentRef.value?.scrollTo({ top: 0 })
activeCategoryRef.value?.scrollIntoView({ block: 'nearest', inline: 'center' })
},
)
function setCategoryButtonRef(element: unknown, categoryIndex: number) {
if (categoryIndex === props.activeCatIndex) {
activeCategoryRef.value = element instanceof HTMLElement ? element : null
}
}
function goToNextCategory() {
if (isLastCategory.value) {
if (!props.canSubmitVote) return
confirmMissingVotes.value = missingVoteCategoryNames.value.length > 0
if (!confirmMissingVotes.value) {
void props.submitVote()
}
return
}
props.catList[props.activeCatIndex + 1]?.onClick()
}
async function submitConfirmedVote() {
await props.submitVote()
}
</script>
<template>
@@ -18,35 +117,94 @@ const props = defineProps<{
<aside class="home-vote-picker__rail">
<div class="home-vote-picker__rail-label">Kategorien</div>
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
<button class="home-vote-picker__category-button" @click="cat.onClick" :style="cat.rowStyle">
<button
:ref="(element) => setCategoryButtonRef(element, __idx)"
class="home-vote-picker__category-button"
@click="cat.onClick"
:style="cat.rowStyle"
>
<span :style="cat.iconStyle">{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
<span :style="cat.checkStyle"></span>
<span v-if="!props.readonlyMode" :style="cat.checkStyle"></span>
</button>
</template>
</aside>
<section class="home-vote-picker__content">
<section ref="contentRef" class="home-vote-picker__content">
<HomeMissingVotesConfirm
v-if="confirmMissingVotes"
:missing-categories="missingVoteCategoryNames"
:on-back="hideMissingVoteConfirm"
:on-confirm="submitConfirmedVote"
:submitting="props.submitting"
:submit-label="submitLabel"
hide-actions
/>
<template v-else>
<header class="home-vote-picker__category-header">
<div>
<p class="home-vote-picker__eyebrow">Kategorie</p>
<h4>{{ props.activeCatName }}</h4>
</div>
<span class="home-vote-picker__hint">Nominee ansehen, Clip prüfen, Favorit:in wählen.</span>
</header>
<p v-if="props.savedVoteActive && !props.readonlyMode" class="home-vote-picker__edit-banner">
Du bearbeitest dein gespeichertes Voting. Speichern ersetzt deine bisherige Auswahl.
</p>
<div class="home-vote-picker__cards">
<template v-for="(nom, __idx) in props.noms" :key="nom.idx ?? __idx">
<article class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
<div class="home-vote-card__identity">
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-vote-card__name-block">
<div class="home-vote-card__name-row">
<h5>{{ nom.name }}</h5>
<span>{{ nom.platform }}</span>
<a
v-if="props.readonlyMode && nom.url"
class="home-nomination-summary-card home-nomination-summary-card--clickable"
:href="nom.url"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
>
<div class="home-nomination-summary-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-nomination-summary-card__body">
<h5>{{ nom.name }}</h5>
<span>{{ nom.handle || nom.platform }}</span>
</div>
</a>
<article v-else-if="props.readonlyMode" class="home-nomination-summary-card">
<div class="home-nomination-summary-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-nomination-summary-card__body">
<h5>{{ nom.name }}</h5>
<span>{{ nom.handle || nom.platform }}</span>
</div>
</article>
<article v-else class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
<div v-if="nom.selected" class="home-vote-card__burst" aria-hidden="true">
<span class="home-vote-card__burst-flare"></span>
<span class="home-vote-card__burst-ring"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--left"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--right"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--up"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--up"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--left"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--right"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--left-small"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--right-small"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--top-small"></span>
</div>
<div class="home-vote-card__top">
<div class="home-vote-card__identity">
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-vote-card__name-block">
<div class="home-vote-card__name-row">
<h5>{{ nom.name }}</h5>
<span>{{ nom.platform }}</span>
</div>
<p>{{ nom.handle }}</p>
</div>
<p>{{ nom.handle }}</p>
</div>
<template v-if="nom.showPick">
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
{{ nom.btnLabel }}
</button>
</template>
</div>
<div class="home-vote-card__clip" @click.stop>
@@ -80,11 +238,6 @@ const props = defineProps<{
</p>
</div>
<template v-if="nom.showPick">
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
{{ nom.btnLabel }}
</button>
</template>
</article>
</template>
@@ -92,17 +245,13 @@ const props = defineProps<{
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
</div>
</div>
</template>
</section>
</div>
<div class="home-modal__vote-footer home-vote-picker__footer">
<div><span>{{ props.voteCount }}</span> / {{ props.totalCats }} Kategorien gewählt</div>
<button
@click="props.submitVote"
:disabled="props.submitting || !props.canSubmitVote"
:class="{ 'home-vote-picker__submit--disabled': !props.canSubmitVote }"
>
{{ props.submitting ? 'Speichert ...' : props.canSubmitVote ? 'Stimmen absenden ✩' : 'Erst Favorit:in wählen' }}
</button>
</div>
<HomeWizardFooter
:progress-text="props.readonlyMode ? `${props.totalCats} Kategorien` : `${props.voteCount} / ${props.totalCats} Kategorien gewählt`"
:helper-text="footerHelperText"
:actions="footerActions"
/>
</template>
@@ -1,26 +1,20 @@
<template>
<section id="nominierte" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);border-top:1px solid rgba(255,255,255,.12);border-bottom:1px solid rgba(255,255,255,.12);">
<span style="position:absolute;top:22px;left:6%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:18px;left:38%;font-size:12px;color:rgba(255,255,255,.28);animation:twinkle 2.6s ease-in-out .4s infinite;"></span>
<span style="position:absolute;top:30px;right:22%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;"></span>
<span style="position:absolute;top:16px;right:7%;font-size:10px;color:rgba(255,255,255,.25);animation:twinkle 2.8s ease-in-out .7s infinite;"></span>
<span style="position:absolute;bottom:24px;left:14%;font-size:14px;color:rgba(255,255,255,.3);animation:twinkle 3.1s ease-in-out .3s infinite;"></span>
<span style="position:absolute;bottom:20px;right:34%;font-size:11px;color:rgba(255,255,255,.25);animation:twinkle 2.5s ease-in-out .9s infinite;"></span>
<span style="position:absolute;bottom:28px;right:8%;font-size:15px;color:rgba(255,255,255,.3);animation:twinkle 3.4s ease-in-out 1.2s infinite;"></span>
<HomeStarField :count="17" variant="light" />
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
<div>
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">&#9733; Die Stars</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:-.4px;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:0;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
</div>
<button @click="onOpenArchive" style="display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#fff6fb;font-weight:600;font-size:15px;cursor:pointer;" style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);">Archiv ansehen &#8594;</button>
</div>
<div style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);">
<div style="display:flex;gap:22px;width:max-content;">
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:360px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<div class="home-winner-rail" style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);scroll-snap-type:x mandatory;scroll-padding-left:0;">
<div class="home-winner-track" style="display:flex;gap:22px;width:max-content;">
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:390px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<div style="position:relative;padding:24px 24px 20px;background:radial-gradient(circle at 28% 24%,rgba(255,95,162,.22),transparent 28%),radial-gradient(circle at 74% 78%,rgba(160,107,255,.2),transparent 32%),linear-gradient(180deg,#26133d 0%,#201132 100%);">
<div style="display:flex;align-items:center;gap:16px;margin-top:28px;margin-bottom:18px;min-height:108px;">
<div style="flex:none;width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffd27a,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:28px;font-weight:700;">{{ initialsFor(winner.name) }}</div>
<div style="display:flex;align-items:center;gap:18px;margin-top:28px;margin-bottom:20px;min-height:120px;">
<div style="flex:none;width:104px;height:104px;border-radius:28px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:34px;font-weight:700;box-shadow:0 18px 42px rgba(0,0,0,.22);">{{ initialsFor(winner.name) }}</div>
<div style="min-width:0;">
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#ffd27a;margin-bottom:8px;">{{ winner.category }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:24px;line-height:1.15;color:#fff6fb;">{{ winner.name }}</div>
@@ -29,9 +23,29 @@
</div>
<a :href="winner.url" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:13px 18px;border-radius:14px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-family:'Fredoka',sans-serif;font-weight:600;font-size:14px;text-decoration:none;box-sizing:border-box;">{{ winner.platform }}</a>
</div>
<div style="padding:0 24px 24px;background:#1b0d2d;">
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">Archivierter Gewinner</div>
<div style="border-radius:18px;overflow:hidden;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#c9b8da;font-family:'Fredoka',sans-serif;font-size:18px;">{{ winner.platform }} · {{ winner.handle }}</div>
<div style="padding:12px 24px 24px;background:#1b0d2d;">
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">{{ winner.hasClip ? winner.clipPlatform : 'Archivierter Gewinner' }}</div>
<iframe
v-if="winner.clipEmbedUrl"
:src="winner.clipEmbedUrl"
:title="winner.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;aspect-ratio:16/9;border:0;border-radius:18px;background:#12091d;box-shadow:0 16px 34px rgba(0,0,0,.22);"
/>
<a
v-else-if="winner.clipUrl"
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="border-radius:18px;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#fff6fb;font-family:'Fredoka',sans-serif;font-size:17px;text-decoration:none;text-align:center;padding:18px;"
>
{{ winner.clipTitle }}
</a>
<div v-else style="border-radius:18px;overflow:hidden;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#c9b8da;font-family:'Fredoka',sans-serif;font-size:18px;">{{ winner.platform }} · {{ winner.handle }}</div>
</div>
</article>
</div>
@@ -41,12 +55,20 @@
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
type WinnerCard = {
category: string
name: string
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
defineProps<{
@@ -0,0 +1,36 @@
<script setup lang="ts">
export interface HomeWizardFooterAction {
label: string
disabled?: boolean
tone?: 'primary' | 'secondary' | 'danger'
onClick: () => Promise<void> | void
}
const props = defineProps<{
progressText: string
helperText: string
actions: HomeWizardFooterAction[]
}>()
</script>
<template>
<footer class="home-wizard-footer">
<div class="home-wizard-footer__copy">
<strong>{{ props.progressText }}</strong>
<span>{{ props.helperText }}</span>
</div>
<div class="home-wizard-footer__actions">
<button
v-for="action in props.actions"
:key="action.label"
type="button"
class="home-wizard-footer__button"
:class="`home-wizard-footer__button--${action.tone ?? 'primary'}`"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</footer>
</template>
File diff suppressed because it is too large Load Diff
@@ -13,8 +13,10 @@ export interface HomeClipSubmitContext {
}
export interface HomeNominationSubmitContext {
categoryIndex: number
streamUrl: string
entries: Array<{
categoryIndex: number
streamUrls: string[]
}>
}
export interface HomeDisplayCategory {
@@ -12,6 +12,7 @@ export interface HomeCategoryListItem {
export interface HomeNomineeListItem {
name: string
handle: string
url: string | null
platform: string
initials: string
clipUrl: string | null
@@ -48,6 +49,12 @@ export interface HomeArchiveWinnerItem {
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
export interface HomeSelectedArchive {
@@ -1,5 +1,6 @@
import { computed, type Ref } from 'vue'
import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAwardsStore } from '../../stores/awards'
type AwardsStore = ReturnType<typeof useAwardsStore>
@@ -37,13 +38,7 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
const selectedArchive = computed(() => ({
year: store.archive.year,
winners: store.archive.items.map((winner) => ({
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
platform: winner.winnerPlatform,
url: winner.winnerUrl,
})),
winners: store.archive.items.map((winner) => toArchiveWinnerItem(winner)),
}))
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
@@ -54,6 +49,36 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
: "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.16);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 10px 24px rgba(20,8,40,.14);opacity:.9;"
}
function toArchiveWinnerItem(winner: {
category: string
winnerName: string
winnerSlug: string
winnerPlatform: string
winnerUrl: string
clipUrl?: string | null
clipTitle?: string | null
clipPlatform?: string | null
clipEmbedStatus?: string | null
}) {
const clipUrl = winner.clipUrl?.trim() || null
const clipTitle = winner.clipTitle?.trim() || 'Gewinner-Clip ansehen'
const clipPlatform = clipUrl ? winner.clipPlatform?.trim() || winnerPlatformLabel(clipUrl) : 'Kein Clip'
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
platform: winner.winnerPlatform,
url: winner.winnerUrl,
clipUrl,
clipTitle,
clipPlatform,
clipEmbedUrl: clipEmbed?.src ?? null,
clipEmbedTitle: clipEmbed?.title ?? clipTitle,
hasClip: Boolean(clipUrl),
}
}
function winnerPlatformKey(url: string) {
const normalized = url.toLowerCase()
if (normalized.includes('twitch.tv')) return 'twitch'
@@ -120,10 +120,11 @@ export function useHomeModalCandidatePresentation(params: {
const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
const clipEmbed = clipUrl ? buildClipEmbed(clipUrl) : null
const clipEmbed = clipUrl && candidate.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
name: candidate.displayName,
handle: candidate.channelSlug,
url: resolveCandidateUrl(candidate.channelUrl, candidate.channelSlug),
platform: candidate.platform,
initials: initialsFor(candidate.displayName),
clipUrl,
@@ -138,7 +139,7 @@ export function useHomeModalCandidatePresentation(params: {
onPick: () => cat && pickNominee(cat.id, idx),
cardStyle: `display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;transition:all .15s;border:1.5px solid ${selected ? '#8b6cdb;background:#f6f1fd;' : '#ece4f6;background:#fff;'}`,
btnStyle: "flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;transition:all .15s;" + (selected ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#f1ecfb;color:#8b6cdb;'),
btnLabel: selected ? '✓ Gewählt' : 'Auswählen',
btnLabel: selected ? 'Auswahl entfernen' : 'Für Clip voten',
}
})
})
@@ -149,6 +150,21 @@ export function useHomeModalCandidatePresentation(params: {
}
}
function resolveCandidateUrl(channelUrl: string | null | undefined, channelSlug: string) {
const url = channelUrl?.trim() || channelSlug.trim()
if (!isHttpUrl(url) || url === '#') return null
return url
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function formatDateLabel(value: string) {
if (!value) return 'Noch nicht terminiert'
const date = new Date(`${value}T00:00:00`)
@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
@@ -21,6 +21,8 @@ export function useHomeLandingState() {
const activeCat = ref(0)
const previewPhase = ref<HomePreviewPhase>('voting')
const clipSubmissionsEnabled = computed(() => store.overview.featureFlags.clipSubmissionsEnabled)
const clipSubmissionDisabledMessage = computed(() => store.overview.featureFlags.clipSubmissionDisabledMessage)
const {
role,
@@ -155,6 +157,7 @@ export function useHomeLandingState() {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -230,7 +233,7 @@ export function useHomeLandingState() {
}
function openClipForPhase(event?: Event) {
if (!nominationPhase.value) {
if (!nominationPhase.value || !clipSubmissionsEnabled.value) {
event?.preventDefault()
return
}
@@ -238,8 +241,8 @@ export function useHomeLandingState() {
openClip(event)
}
watch(previewPhase, (phase) => {
if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) {
watch([previewPhase, clipSubmissionsEnabled], ([phase, clipsEnabled]) => {
if ((phase !== 'voting' && modal.value === 'vote') || ((phase !== 'nomination' || !clipsEnabled) && modal.value === 'clip')) {
closeModal()
}
})
@@ -282,6 +285,7 @@ export function useHomeLandingState() {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCatName,
phaseCardTitle,
phaseCardDescription,
@@ -318,6 +322,8 @@ export function useHomeLandingState() {
catOptions,
canSubmitClip,
clipSubmitStyle,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
archiveYears,
selectedArchive,
winnerShowcase,
@@ -3,7 +3,6 @@ import type { Router } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
@@ -38,7 +37,6 @@ interface UseHomeLandingViewEffectsParams {
preparationPhase: Readonly<Ref<boolean>>
completedPhase: Readonly<Ref<boolean>>
initializeHomeInteractions: () => Promise<void>
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
}
@@ -59,14 +57,12 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
} = params
const rootEl = ref<HTMLElement | null>(null)
const landingLoaderVisible = ref(true)
const nominationCatEl = ref<HTMLSelectElement | null>(null)
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
const stickyCountdownVisible = ref(false)
const clipUrlEl = ref<HTMLInputElement | null>(null)
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
@@ -81,6 +77,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
bhEl: ref<HTMLElement | null>(null),
bmEl: ref<HTMLElement | null>(null),
bsEl: ref<HTMLElement | null>(null),
stickyLabelEl: ref<HTMLElement | null>(null),
sbdEl: ref<HTMLElement | null>(null),
sbhEl: ref<HTMLElement | null>(null),
sbmEl: ref<HTMLElement | null>(null),
sbsEl: ref<HTMLElement | null>(null),
}
let timer: ReturnType<typeof setInterval> | null = null
let landingLoaderTimer: ReturnType<typeof setTimeout> | null = null
@@ -97,13 +98,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
})
}
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
function assignDomRefs() {
const root = rootEl.value
if (!root) return
@@ -117,8 +111,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]')
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
countdownRefs.stickyLabelEl.value = root.querySelector('[data-dc-ref="stickyCountdownLabelRef"]')
countdownRefs.sbdEl.value = root.querySelector('[data-dc-ref="sbdRef"]')
countdownRefs.sbhEl.value = root.querySelector('[data-dc-ref="sbhRef"]')
countdownRefs.sbmEl.value = root.querySelector('[data-dc-ref="sbmRef"]')
countdownRefs.sbsEl.value = root.querySelector('[data-dc-ref="sbsRef"]')
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
@@ -130,18 +127,30 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
root.querySelectorAll<HTMLElement>('[style-hover]').forEach((element) => {
if (element.dataset.hoverBound === '1') return
element.dataset.hoverBound = '1'
const base = element.getAttribute('style') ?? ''
const hover = element.getAttribute('style-hover') ?? ''
element.addEventListener('mouseenter', () => { element.setAttribute('style', `${base}${hover}`) })
element.addEventListener('mouseleave', () => { element.setAttribute('style', base) })
element.addEventListener('mouseenter', () => {
const base = element.getAttribute('style') ?? ''
element.dataset.hoverBaseStyle = base
element.setAttribute('style', `${base}${hover}`)
})
element.addEventListener('mouseleave', () => {
element.setAttribute('style', element.dataset.hoverBaseStyle ?? '')
delete element.dataset.hoverBaseStyle
})
})
root.querySelectorAll<HTMLElement>('[style-focus]').forEach((element) => {
if (element.dataset.focusBound === '1') return
element.dataset.focusBound = '1'
const base = element.getAttribute('style') ?? ''
const focus = element.getAttribute('style-focus') ?? ''
element.addEventListener('focus', () => { element.setAttribute('style', `${base}${focus}`) })
element.addEventListener('blur', () => { element.setAttribute('style', base) })
element.addEventListener('focus', () => {
const base = element.getAttribute('style') ?? ''
element.dataset.focusBaseStyle = base
element.setAttribute('style', `${base}${focus}`)
})
element.addEventListener('blur', () => {
element.setAttribute('style', element.dataset.focusBaseStyle ?? '')
delete element.dataset.focusBaseStyle
})
})
}
@@ -170,6 +179,26 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
setText(countdownRefs.bhEl.value, pad(showCountdown.h))
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
setText(countdownRefs.bsEl.value, pad(showCountdown.s))
setText(countdownRefs.stickyLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in')
setText(countdownRefs.sbdEl.value, pad(showCountdown.d))
setText(countdownRefs.sbhEl.value, pad(showCountdown.h))
setText(countdownRefs.sbmEl.value, pad(showCountdown.m))
setText(countdownRefs.sbsEl.value, pad(showCountdown.s))
}
function updateStickyCountdownVisibility() {
if (modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value) {
stickyCountdownVisible.value = false
return
}
const hero = rootEl.value?.querySelector('.home-hero')
if (!(hero instanceof HTMLElement)) {
stickyCountdownVisible.value = false
return
}
stickyCountdownVisible.value = hero.getBoundingClientRect().bottom <= 0
}
function resolveSelectedPhaseKey(): HomeTimelineKey {
@@ -183,6 +212,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
nextTick(setupDom)
updateStickyCountdownVisibility()
})
watch([submitted, streamLive, archiveYear], () => {
@@ -204,6 +234,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
await initializeHomeInteractions()
setupDom()
tick()
updateStickyCountdownVisibility()
window.addEventListener('scroll', updateStickyCountdownVisibility, { passive: true })
window.addEventListener('resize', updateStickyCountdownVisibility)
timer = setInterval(tick, 1000)
landingLoaderTimer = setTimeout(() => {
landingLoaderVisible.value = false
@@ -212,6 +245,8 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
onBeforeUnmount(() => {
document.body.style.overflow = ''
window.removeEventListener('scroll', updateStickyCountdownVisibility)
window.removeEventListener('resize', updateStickyCountdownVisibility)
if (timer) clearInterval(timer)
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
})
@@ -219,7 +254,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
return {
setRootEl,
landingLoaderVisible,
handleNominationSubmit,
stickyCountdownVisible,
handleClipSubmit,
}
}
@@ -21,12 +21,15 @@ export function useHomeParticipationActions(params: {
openModal: (kind: HomeInteractionModalKind) => void
closeAccountModal: () => void
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
clipSubmissionsEnabled: ComputedRef<boolean>
clipSubmissionDisabledMessage: ComputedRef<string>
}) {
const {
store,
@@ -43,12 +46,15 @@ export function useHomeParticipationActions(params: {
openModal,
closeAccountModal,
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
} = params
const {
@@ -64,6 +70,7 @@ export function useHomeParticipationActions(params: {
routerPush,
displayCategories,
votes,
savedVoteCount,
formError,
deleteConfirm,
accountActionError,
@@ -85,6 +92,7 @@ export function useHomeParticipationActions(params: {
activeCat,
previewPhase,
votes,
savedVoteCount,
submitted,
submitting,
formError,
@@ -94,6 +102,8 @@ export function useHomeParticipationActions(params: {
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName: twitchUser,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
})
async function initializeHomeInteractions() {
@@ -10,10 +10,12 @@ export function useHomeParticipationPresentation(params: {
modal: Ref<null | HomeInteractionModalKind>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
clipSubmissionsEnabled: ComputedRef<boolean>
}) {
const {
store,
@@ -22,25 +24,28 @@ export function useHomeParticipationPresentation(params: {
modal,
previewPhase,
votes,
savedVoteCount,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
} = params
const notSubmitted = computed(() => !submitted.value)
const voteCount = computed(() => Object.keys(votes.value).length)
const totalCats = computed(() => displayCategories.value.length)
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
const savedVoteActive = computed(() => savedVoteCount.value > 0)
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
const activeCatName = computed(() => activeCategory.value?.name ?? '')
const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt'))
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩')
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Der Stream-Link wurde gespeichert und landet im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Deine Links wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : savedVoteActive.value ? 'Danke! Deine geänderte Auswahl wurde gespeichert.' : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` })))
const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` })))
const canSubmitClip = computed(() => previewPhase.value === 'nomination' && clipDsgvo.value)
const canSubmitClip = computed(() => clipSubmissionsEnabled.value && previewPhase.value === 'nomination' && clipDsgvo.value)
const clipSubmitStyle = computed(() => canSubmitClip.value ? "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" : "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;")
return {
@@ -48,6 +53,7 @@ export function useHomeParticipationPresentation(params: {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -12,6 +12,7 @@ export function useHomeParticipationSessionActions(params: {
routerPush: (path: string) => Promise<unknown>
displayCategories: ComputedRef<HomeDisplayCategory[]>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
formError: Ref<string>
deleteConfirm: Ref<boolean>
accountActionError: Ref<string>
@@ -24,6 +25,7 @@ export function useHomeParticipationSessionActions(params: {
routerPush,
displayCategories,
votes,
savedVoteCount,
formError,
deleteConfirm,
accountActionError,
@@ -60,8 +62,10 @@ export function useHomeParticipationSessionActions(params: {
}
}
votes.value = nextVotes
savedVoteCount.value = Object.keys(nextVotes).length
} catch {
votes.value = {}
savedVoteCount.value = 0
}
}
@@ -78,6 +82,7 @@ export function useHomeParticipationSessionActions(params: {
accountActionError.value = ''
clipDsgvo.value = false
votes.value = {}
savedVoteCount.value = 0
await routerPush('/login')
}
@@ -89,6 +94,7 @@ export function useHomeParticipationSessionActions(params: {
deleteConfirm.value = false
clipDsgvo.value = false
votes.value = {}
savedVoteCount.value = 0
await store.loadHomeData()
await routerPush('/login')
} catch (error) {

Some files were not shown because too many files have changed in this diff Show More