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:
@@ -184,5 +184,6 @@ public static class SeasonMappings
|
|||||||
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
||||||
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
|
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
|
||||||
new FooterLinkDto("sponsors", "Sponsoren & Partner", settings.SponsorsUrl, settings.SponsorsContent),
|
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 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(
|
public sealed record UpdateClipStatusRequest(
|
||||||
string Status,
|
string Status,
|
||||||
string? ReviewNote);
|
string? ReviewNote);
|
||||||
@@ -116,3 +124,15 @@ public sealed record AdminRiskRuleDto(
|
|||||||
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
|
public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules);
|
||||||
|
|
||||||
public sealed record UpdateRiskRulesRequest(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);
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ public sealed record AdminCandidateItemDto(
|
|||||||
int CategoryId,
|
int CategoryId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
string Platform);
|
string Platform,
|
||||||
|
string AcceptanceStatus,
|
||||||
|
string? AcceptanceNote,
|
||||||
|
string? ClipCompilationUrl,
|
||||||
|
string? ClipCompilationTitle,
|
||||||
|
string? ClipCompilationPlatform,
|
||||||
|
string ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record AdminAwardResultItemDto(
|
public sealed record AdminAwardResultItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
@@ -102,7 +108,13 @@ public sealed record UpsertCandidateRequest(
|
|||||||
int CategoryId,
|
int CategoryId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
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(
|
public sealed record SetAwardResultRequest(
|
||||||
int CategoryId,
|
int CategoryId,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public sealed record AdminSiteSettingsResponse(
|
|||||||
string ContactContent,
|
string ContactContent,
|
||||||
string SponsorsUrl,
|
string SponsorsUrl,
|
||||||
string SponsorsContent,
|
string SponsorsContent,
|
||||||
|
string ShowactsUrl,
|
||||||
|
string ShowactsContent,
|
||||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq);
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ public sealed record UpdateSiteSettingsRequest(
|
|||||||
string ContactContent,
|
string ContactContent,
|
||||||
string SponsorsUrl,
|
string SponsorsUrl,
|
||||||
string SponsorsContent,
|
string SponsorsContent,
|
||||||
|
string ShowactsUrl,
|
||||||
|
string ShowactsContent,
|
||||||
PublicSocialLinkDto[] SocialLinks,
|
PublicSocialLinkDto[] SocialLinks,
|
||||||
FaqItemDto[] Faq);
|
FaqItemDto[] Faq);
|
||||||
|
|
||||||
@@ -49,6 +53,24 @@ public sealed record AdminOperationalSettingsResponse(
|
|||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
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(
|
public sealed record UpdateOperationalSettingsRequest(
|
||||||
bool DemoLoginEnabled,
|
bool DemoLoginEnabled,
|
||||||
string DemoLoginEmail,
|
string DemoLoginEmail,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -20,7 +20,11 @@ public sealed record WinnerPreviewDto(
|
|||||||
string WinnerName,
|
string WinnerName,
|
||||||
string WinnerSlug,
|
string WinnerSlug,
|
||||||
string WinnerPlatform,
|
string WinnerPlatform,
|
||||||
string WinnerUrl);
|
string WinnerUrl,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record ArchiveYearDto(
|
public sealed record ArchiveYearDto(
|
||||||
int Year,
|
int Year,
|
||||||
@@ -57,6 +61,14 @@ public sealed record PublicSiteStatusResponse(
|
|||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
|
|
||||||
|
public sealed record PublicFeatureFlagsDto(
|
||||||
|
bool ClipSubmissionsEnabled,
|
||||||
|
bool ClipReviewEnabled,
|
||||||
|
string ClipSubmissionDisabledMessage,
|
||||||
|
bool ShowactApplicationsEnabled,
|
||||||
|
string ShowactApplicationDisabledMessage,
|
||||||
|
bool SponsorsVisible);
|
||||||
|
|
||||||
public sealed record OverviewResponse(
|
public sealed record OverviewResponse(
|
||||||
int SeasonId,
|
int SeasonId,
|
||||||
int Year,
|
int Year,
|
||||||
@@ -72,4 +84,5 @@ public sealed record OverviewResponse(
|
|||||||
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
||||||
IEnumerable<ArchiveYearDto> ArchiveYears,
|
IEnumerable<ArchiveYearDto> ArchiveYears,
|
||||||
PublicSiteContentDto SiteContent,
|
PublicSiteContentDto SiteContent,
|
||||||
|
PublicFeatureFlagsDto FeatureFlags,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ public sealed record CandidateSummaryDto(
|
|||||||
int Id,
|
int Id,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
|
string ChannelUrl,
|
||||||
string Platform,
|
string Platform,
|
||||||
string? ClipUrl,
|
string? ClipUrl,
|
||||||
string? ClipTitle,
|
string? ClipTitle,
|
||||||
string? ClipPlatform);
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record PublicCategoryDetailDto(
|
public sealed record PublicCategoryDetailDto(
|
||||||
int Id,
|
int Id,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ public sealed record WinnerArchiveItemDto(
|
|||||||
string WinnerName,
|
string WinnerName,
|
||||||
string WinnerSlug,
|
string WinnerSlug,
|
||||||
string WinnerPlatform,
|
string WinnerPlatform,
|
||||||
string WinnerUrl);
|
string WinnerUrl,
|
||||||
|
string? ClipUrl,
|
||||||
|
string? ClipTitle,
|
||||||
|
string? ClipPlatform,
|
||||||
|
string? ClipEmbedStatus);
|
||||||
|
|
||||||
public sealed record WinnerArchiveResponse(
|
public sealed record WinnerArchiveResponse(
|
||||||
int Year,
|
int Year,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||||
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
|
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<SiteSettings> SiteSettings => Set<SiteSettings>();
|
||||||
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
|
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
|
||||||
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
|
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.ImprintUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.ContactUrl).HasMaxLength(400);
|
entity.Property(item => item.ContactUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.SponsorsUrl).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.DemoLoginEmail).HasMaxLength(180);
|
||||||
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
|
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
|
||||||
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
|
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.TwitchScope).HasMaxLength(300);
|
||||||
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
|
||||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
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 =>
|
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.DisplayName).HasMaxLength(120);
|
||||||
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
|
||||||
entity.Property(item => item.Platform).HasMaxLength(40);
|
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 =>
|
modelBuilder.Entity<Nomination>(entity =>
|
||||||
@@ -169,6 +187,34 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.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);
|
SeedData.Apply(modelBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
|
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"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -40,6 +64,12 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
|
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" (
|
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
"SeasonId" integer NULL,
|
"SeasonId" integer NULL,
|
||||||
@@ -121,6 +151,74 @@ public static class OperationalTablesBootstrapper
|
|||||||
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
|
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
|
||||||
ON "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 $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -138,6 +236,24 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "Nominations"
|
ALTER TABLE "Nominations"
|
||||||
ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL;
|
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"
|
ALTER TABLE "Nominations"
|
||||||
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,17 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
|||||||
ContactContent = SeedCatalog.DefaultContactContent,
|
ContactContent = SeedCatalog.DefaultContactContent,
|
||||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||||
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
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),
|
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[]
|
SocialLinksJson = JsonSerializer.Serialize(new[]
|
||||||
{
|
{
|
||||||
new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true },
|
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);
|
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))
|
if (string.IsNullOrWhiteSpace(settings.ImprintContent))
|
||||||
{
|
{
|
||||||
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
|
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
|
||||||
@@ -55,6 +75,16 @@ public static partial class SeedDataBootstrapper
|
|||||||
{
|
{
|
||||||
settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent;
|
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)
|
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
|
||||||
@@ -87,4 +117,30 @@ public static partial class SeedDataBootstrapper
|
|||||||
|
|
||||||
private static bool HasValidRiskRules(string? json) =>
|
private static bool HasValidRiskRules(string? json) =>
|
||||||
RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,10 @@ public sealed class Candidate
|
|||||||
public string DisplayName { get; set; } = string.Empty;
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
public string ChannelSlug { get; set; } = string.Empty;
|
public string ChannelSlug { get; set; } = string.Empty;
|
||||||
public string Platform { get; set; } = "Twitch";
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -16,9 +16,20 @@ public sealed class SiteSettings
|
|||||||
public string ContactContent { get; set; } = string.Empty;
|
public string ContactContent { get; set; } = string.Empty;
|
||||||
public string SponsorsUrl { get; set; } = string.Empty;
|
public string SponsorsUrl { get; set; } = string.Empty;
|
||||||
public string SponsorsContent { 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 SocialLinksJson { get; set; } = "[]";
|
||||||
public string FaqJson { get; set; } = "[]";
|
public string FaqJson { get; set; } = "[]";
|
||||||
public string RiskRulesJson { 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 DemoLoginManagedByDatabase { get; set; }
|
||||||
public bool DemoLoginEnabled { get; set; }
|
public bool DemoLoginEnabled { get; set; }
|
||||||
public string DemoLoginEmail { get; set; } = string.Empty;
|
public string DemoLoginEmail { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ public static class AdminEndpoints
|
|||||||
group.MapAdminDashboardEndpoints();
|
group.MapAdminDashboardEndpoints();
|
||||||
group.MapAdminSeasonManagementEndpoints();
|
group.MapAdminSeasonManagementEndpoints();
|
||||||
group.MapAdminModerationEndpoints();
|
group.MapAdminModerationEndpoints();
|
||||||
|
group.MapAdminExtrasEndpoints();
|
||||||
group.MapAdminTeamEndpoints();
|
group.MapAdminTeamEndpoints();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -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))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||||
.WithName("RejectAdminNomination")
|
.WithName("RejectAdminNomination")
|
||||||
.WithOpenApi();
|
.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)
|
group.MapGet("/risk-flags", GetRiskFlags)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||||
.WithName("GetAdminRiskFlags")
|
.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
|
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(
|
private static async Task<IResult> CreateCandidate(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
int seasonId,
|
int seasonId,
|
||||||
@@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
var normalizedDisplayName = request.DisplayName.Trim();
|
var normalizedDisplayName = request.DisplayName.Trim();
|
||||||
var normalizedChannelSlug = request.ChannelSlug.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 =>
|
if (await db.Candidates.AnyAsync(item =>
|
||||||
item.SeasonId == seasonId
|
item.SeasonId == seasonId
|
||||||
&& item.CategoryId == request.CategoryId
|
&& 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." });
|
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
|
var candidate = new Candidate
|
||||||
{
|
{
|
||||||
SeasonId = seasonId,
|
SeasonId = seasonId,
|
||||||
@@ -47,6 +78,12 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
DisplayName = normalizedDisplayName,
|
DisplayName = normalizedDisplayName,
|
||||||
ChannelSlug = normalizedChannelSlug,
|
ChannelSlug = normalizedChannelSlug,
|
||||||
Platform = request.Platform.Trim(),
|
Platform = request.Platform.Trim(),
|
||||||
|
AcceptanceStatus = normalizedAcceptanceStatus,
|
||||||
|
AcceptanceNote = normalizedAcceptanceNote,
|
||||||
|
ClipCompilationUrl = normalizedClipUrl,
|
||||||
|
ClipCompilationTitle = normalizedClipTitle,
|
||||||
|
ClipCompilationPlatform = normalizedClipPlatform,
|
||||||
|
ClipEmbedStatus = normalizedClipEmbedStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Candidates.Add(candidate);
|
db.Candidates.Add(candidate);
|
||||||
@@ -56,7 +93,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
"candidate",
|
"candidate",
|
||||||
request.DisplayName.Trim(),
|
request.DisplayName.Trim(),
|
||||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
$"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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
@@ -92,6 +129,20 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
var normalizedDisplayName = request.DisplayName.Trim();
|
var normalizedDisplayName = request.DisplayName.Trim();
|
||||||
var normalizedChannelSlug = request.ChannelSlug.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 =>
|
if (await db.Candidates.AnyAsync(item =>
|
||||||
item.SeasonId == candidate.SeasonId
|
item.SeasonId == candidate.SeasonId
|
||||||
&& item.CategoryId == request.CategoryId
|
&& 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." });
|
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.CategoryId = request.CategoryId;
|
||||||
candidate.DisplayName = normalizedDisplayName;
|
candidate.DisplayName = normalizedDisplayName;
|
||||||
candidate.ChannelSlug = normalizedChannelSlug;
|
candidate.ChannelSlug = normalizedChannelSlug;
|
||||||
candidate.Platform = request.Platform.Trim();
|
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(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -113,7 +184,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
"candidate",
|
"candidate",
|
||||||
candidate.Id.ToString(),
|
candidate.Id.ToString(),
|
||||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
$"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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
@@ -146,4 +217,34 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
return Results.Ok(new { deleted = true, candidateId });
|
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.CategoryId,
|
||||||
item.DisplayName,
|
item.DisplayName,
|
||||||
item.ChannelSlug,
|
item.ChannelSlug,
|
||||||
item.Platform))
|
item.Platform,
|
||||||
|
item.AcceptanceStatus,
|
||||||
|
item.AcceptanceNote,
|
||||||
|
item.ClipCompilationUrl,
|
||||||
|
item.ClipCompilationTitle,
|
||||||
|
item.ClipCompilationPlatform,
|
||||||
|
item.ClipEmbedStatus))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var candidateCounts = candidates
|
var candidateCounts = candidates
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||||
.WithName("DeleteAdminResult")
|
.WithName("DeleteAdminResult")
|
||||||
.WithOpenApi();
|
.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;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Backend.Common;
|
using Backend.Common;
|
||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -14,6 +16,17 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
private const int MaxCandidateDisplayNameLength = 120;
|
private const int MaxCandidateDisplayNameLength = 120;
|
||||||
private const int MaxCandidateChannelSlugLength = 120;
|
private const int MaxCandidateChannelSlugLength = 120;
|
||||||
private const int MaxCandidatePlatformLength = 60;
|
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)
|
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(
|
private static string[] BuildNewSeasonReadinessIssues(
|
||||||
string currentPhase,
|
string currentPhase,
|
||||||
bool isCurrent,
|
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." });
|
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;
|
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." });
|
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 =>
|
var existingResult = await db.Results.FirstOrDefaultAsync(item =>
|
||||||
item.SeasonId == seasonId
|
item.SeasonId == seasonId
|
||||||
&& item.CategoryId == request.CategoryId);
|
&& item.CategoryId == request.CategoryId);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
{
|
{
|
||||||
private const string FallbackMaintenanceTitle = "Sternenpause";
|
private const string FallbackMaintenanceTitle = "Sternenpause";
|
||||||
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
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)
|
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,14 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
.WithName("UpdateAdminOperationalSettings")
|
.WithName("UpdateAdminOperationalSettings")
|
||||||
.WithOpenApi();
|
.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;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +66,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.ContactContent,
|
settings.ContactContent,
|
||||||
settings.SponsorsUrl,
|
settings.SponsorsUrl,
|
||||||
settings.SponsorsContent,
|
settings.SponsorsContent,
|
||||||
|
settings.ShowactsUrl,
|
||||||
|
settings.ShowactsContent,
|
||||||
SeasonMappings.ReadSocialLinks(settings),
|
SeasonMappings.ReadSocialLinks(settings),
|
||||||
SeasonMappings.ReadFaqItems(settings)));
|
SeasonMappings.ReadFaqItems(settings)));
|
||||||
}
|
}
|
||||||
@@ -100,6 +111,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.ContactContent = request.ContactContent.Trim();
|
settings.ContactContent = request.ContactContent.Trim();
|
||||||
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
||||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||||
|
settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
|
||||||
|
settings.ShowactsContent = request.ShowactsContent.Trim();
|
||||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
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)
|
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, 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 });
|
return Results.BadRequest(new { message = errorMessage });
|
||||||
}
|
}
|
||||||
@@ -144,6 +158,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
ImprintUrl = imprintUrl,
|
ImprintUrl = imprintUrl,
|
||||||
ContactUrl = contactUrl,
|
ContactUrl = contactUrl,
|
||||||
SponsorsUrl = sponsorsUrl,
|
SponsorsUrl = sponsorsUrl,
|
||||||
|
ShowactsUrl = showactsUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
||||||
@@ -187,6 +202,108 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
public string ImprintUrl { get; set; } = string.Empty;
|
public string ImprintUrl { get; set; } = string.Empty;
|
||||||
public string ContactUrl { get; set; } = string.Empty;
|
public string ContactUrl { get; set; } = string.Empty;
|
||||||
public string SponsorsUrl { 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)
|
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);
|
||||||
|
}
|
||||||
@@ -28,6 +28,22 @@ public static partial class PublicEndpoints
|
|||||||
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
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 season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
||||||
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
||||||
if (clipSeasonResolution.Result is not null)
|
if (clipSeasonResolution.Result is not null)
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public static partial class PublicEndpoints
|
|||||||
.WithName("GetWinnerArchive")
|
.WithName("GetWinnerArchive")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/seasons/{year:int}/sponsors", GetSponsors)
|
||||||
|
.WithName("GetPublicSponsors")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
group.MapGet("/seasons/{year:int}/me", GetUserParticipation)
|
||||||
.WithName("GetUserParticipation")
|
.WithName("GetUserParticipation")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
@@ -41,6 +45,11 @@ public static partial class PublicEndpoints
|
|||||||
.WithName("CreateClip")
|
.WithName("CreateClip")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/showacts", CreateShowactApplication)
|
||||||
|
.RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy)
|
||||||
|
.WithName("CreateShowactApplication")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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." });
|
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
|
var category = await db.Categories
|
||||||
.Include(item => item.Season)
|
.Include(item => item.Season)
|
||||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public static partial class PublicEndpoints
|
|||||||
WinnerName = result.Candidate.DisplayName,
|
WinnerName = result.Candidate.DisplayName,
|
||||||
WinnerSlug = result.Candidate.ChannelSlug,
|
WinnerSlug = result.Candidate.ChannelSlug,
|
||||||
WinnerPlatform = result.Candidate.Platform,
|
WinnerPlatform = result.Candidate.Platform,
|
||||||
|
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||||
|
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||||
|
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||||
|
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
@@ -52,7 +56,11 @@ public static partial class PublicEndpoints
|
|||||||
result.WinnerName,
|
result.WinnerName,
|
||||||
result.WinnerSlug,
|
result.WinnerSlug,
|
||||||
result.WinnerPlatform,
|
result.WinnerPlatform,
|
||||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||||
|
result.ClipUrl,
|
||||||
|
result.ClipTitle,
|
||||||
|
result.ClipPlatform,
|
||||||
|
result.ClipEmbedStatus))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var archiveYearRows = await db.Results
|
var archiveYearRows = await db.Results
|
||||||
@@ -110,6 +118,17 @@ public static partial class PublicEndpoints
|
|||||||
siteSettings.PrivacyPolicyContent,
|
siteSettings.PrivacyPolicyContent,
|
||||||
SeasonMappings.ReadSocialLinks(siteSettings),
|
SeasonMappings.ReadSocialLinks(siteSettings),
|
||||||
SeasonMappings.BuildFooterLinks(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));
|
SeasonMappings.ReadFaqItems(siteSettings));
|
||||||
|
|
||||||
return Results.Ok(response);
|
return Results.Ok(response);
|
||||||
|
|||||||
@@ -54,17 +54,36 @@ public static partial class PublicEndpoints
|
|||||||
category.GroupName,
|
category.GroupName,
|
||||||
category.Description,
|
category.Description,
|
||||||
category.MaxNomineesPerUser,
|
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 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(
|
return new CandidateSummaryDto(
|
||||||
candidate.Id,
|
candidate.Id,
|
||||||
candidate.DisplayName,
|
candidate.DisplayName,
|
||||||
candidate.ChannelSlug,
|
candidate.ChannelSlug,
|
||||||
|
SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug),
|
||||||
candidate.Platform,
|
candidate.Platform,
|
||||||
clip?.ClipUrl,
|
clipUrl,
|
||||||
clip?.Title,
|
clipTitle,
|
||||||
clip?.Platform);
|
clipPlatform,
|
||||||
|
clipEmbedStatus);
|
||||||
}).ToArray()))
|
}).ToArray()))
|
||||||
.ToArray()));
|
.ToArray()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public static partial class PublicEndpoints
|
|||||||
WinnerName = result.Candidate.DisplayName,
|
WinnerName = result.Candidate.DisplayName,
|
||||||
WinnerSlug = result.Candidate.ChannelSlug,
|
WinnerSlug = result.Candidate.ChannelSlug,
|
||||||
WinnerPlatform = result.Candidate.Platform,
|
WinnerPlatform = result.Candidate.Platform,
|
||||||
|
ClipUrl = result.Candidate.ClipCompilationUrl,
|
||||||
|
ClipTitle = result.Candidate.ClipCompilationTitle,
|
||||||
|
ClipPlatform = result.Candidate.ClipCompilationPlatform,
|
||||||
|
ClipEmbedStatus = result.Candidate.ClipEmbedStatus,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
@@ -44,7 +48,11 @@ public static partial class PublicEndpoints
|
|||||||
result.WinnerName,
|
result.WinnerName,
|
||||||
result.WinnerSlug,
|
result.WinnerSlug,
|
||||||
result.WinnerPlatform,
|
result.WinnerPlatform,
|
||||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug),
|
||||||
|
result.ClipUrl,
|
||||||
|
result.ClipTitle,
|
||||||
|
result.ClipPlatform,
|
||||||
|
result.ClipEmbedStatus))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||||
|
|||||||
+1527
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";
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1552
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"));
|
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")
|
b.Property<int>("CategoryId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -177,6 +188,25 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("character varying(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")
|
b.Property<string>("DisplayName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
@@ -202,8 +232,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 1,
|
CategoryId = 1,
|
||||||
ChannelSlug = "@hoshimimiyu",
|
ChannelSlug = "@hoshimimiyu",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Hoshimi Miyu",
|
DisplayName = "Hoshimi Miyu",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -211,8 +243,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 1,
|
CategoryId = 1,
|
||||||
ChannelSlug = "@kurainu",
|
ChannelSlug = "@kurainu",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Kurainu",
|
DisplayName = "Kurainu",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -220,8 +254,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 1,
|
CategoryId = 1,
|
||||||
ChannelSlug = "@shiroch",
|
ChannelSlug = "@shiroch",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Shiro Ch.",
|
DisplayName = "Shiro Ch.",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -229,8 +265,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 4,
|
Id = 4,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 2,
|
CategoryId = 2,
|
||||||
ChannelSlug = "@kurainu",
|
ChannelSlug = "@kurainu",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Kurainu 3D Live",
|
DisplayName = "Kurainu 3D Live",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -238,8 +276,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 5,
|
Id = 5,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 2,
|
CategoryId = 2,
|
||||||
ChannelSlug = "@aoisakura",
|
ChannelSlug = "@aoisakura",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Aoi Sakura Showcase",
|
DisplayName = "Aoi Sakura Showcase",
|
||||||
Platform = "YouTube",
|
Platform = "YouTube",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -247,8 +287,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 6,
|
Id = 6,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 3,
|
CategoryId = 3,
|
||||||
ChannelSlug = "@pyonkichikingdom",
|
ChannelSlug = "@pyonkichikingdom",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Pyonkichi Kingdom",
|
DisplayName = "Pyonkichi Kingdom",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -256,8 +298,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 7,
|
Id = 7,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 4,
|
CategoryId = 4,
|
||||||
ChannelSlug = "@moonrelay",
|
ChannelSlug = "@moonrelay",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Moonrelay",
|
DisplayName = "Moonrelay",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 1
|
SeasonId = 1
|
||||||
@@ -265,8 +309,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 8,
|
Id = 8,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 5,
|
CategoryId = 5,
|
||||||
ChannelSlug = "@hoshimimiyu",
|
ChannelSlug = "@hoshimimiyu",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Hoshimi Miyu",
|
DisplayName = "Hoshimi Miyu",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 2
|
SeasonId = 2
|
||||||
@@ -274,8 +320,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 9,
|
Id = 9,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 6,
|
CategoryId = 6,
|
||||||
ChannelSlug = "@kurainu",
|
ChannelSlug = "@kurainu",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Kurainu 3D Live",
|
DisplayName = "Kurainu 3D Live",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 2
|
SeasonId = 2
|
||||||
@@ -283,8 +331,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 10,
|
Id = 10,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 7,
|
CategoryId = 7,
|
||||||
ChannelSlug = "@pyonkichikingdom",
|
ChannelSlug = "@pyonkichikingdom",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Pyonkichi Kingdom",
|
DisplayName = "Pyonkichi Kingdom",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 2
|
SeasonId = 2
|
||||||
@@ -292,8 +342,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 11,
|
Id = 11,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 8,
|
CategoryId = 8,
|
||||||
ChannelSlug = "@aoisakura",
|
ChannelSlug = "@aoisakura",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Aoi Sakura",
|
DisplayName = "Aoi Sakura",
|
||||||
Platform = "YouTube",
|
Platform = "YouTube",
|
||||||
SeasonId = 3
|
SeasonId = 3
|
||||||
@@ -301,8 +353,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 12,
|
Id = 12,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 9,
|
CategoryId = 9,
|
||||||
ChannelSlug = "@starbyte",
|
ChannelSlug = "@starbyte",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Starbyte",
|
DisplayName = "Starbyte",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 3
|
SeasonId = 3
|
||||||
@@ -310,8 +364,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 13,
|
Id = 13,
|
||||||
|
AcceptanceStatus = "open",
|
||||||
CategoryId = 10,
|
CategoryId = 10,
|
||||||
ChannelSlug = "@tenshivox",
|
ChannelSlug = "@tenshivox",
|
||||||
|
ClipEmbedStatus = "unchecked",
|
||||||
DisplayName = "Tenshi Vox",
|
DisplayName = "Tenshi Vox",
|
||||||
Platform = "Twitch",
|
Platform = "Twitch",
|
||||||
SeasonId = 4
|
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 =>
|
modelBuilder.Entity("Backend.Domain.SiteSettings", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -852,6 +995,24 @@ namespace Backend.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
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")
|
b.Property<string>("ContactContent")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -933,6 +1094,12 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(400)
|
.HasMaxLength(400)
|
||||||
.HasColumnType("character varying(400)");
|
.HasColumnType("character varying(400)");
|
||||||
|
|
||||||
|
b.Property<string>("NominationLinkBlacklistJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
b.Property<string>("PrivacyEmail")
|
b.Property<string>("PrivacyEmail")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(160)
|
.HasMaxLength(160)
|
||||||
@@ -953,6 +1120,27 @@ namespace Backend.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.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")
|
b.Property<string>("SocialLinksJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -966,6 +1154,11 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(400)
|
.HasMaxLength(400)
|
||||||
.HasColumnType("character varying(400)");
|
.HasColumnType("character varying(400)");
|
||||||
|
|
||||||
|
b.Property<bool>("SponsorsVisible")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
b.Property<bool>("TwitchAuthManagedByDatabase")
|
b.Property<bool>("TwitchAuthManagedByDatabase")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -989,6 +1182,12 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
.HasColumnType("character varying(300)");
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("WorkflowRulesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("SiteSettings");
|
b.ToTable("SiteSettings");
|
||||||
@@ -997,6 +1196,10 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 1,
|
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.",
|
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",
|
ContactUrl = "https://vtuber-star-awards.de/kontakt",
|
||||||
DemoLoginDisplayName = "Jayuhime Admin",
|
DemoLoginDisplayName = "Jayuhime Admin",
|
||||||
@@ -1015,22 +1218,84 @@ namespace Backend.Migrations
|
|||||||
MaintenanceModeEnabled = false,
|
MaintenanceModeEnabled = false,
|
||||||
MaintenanceTitle = "Sternenpause",
|
MaintenanceTitle = "Sternenpause",
|
||||||
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
|
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",
|
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.",
|
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)),
|
PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||||
PrivacyPolicyUpdatedBy = "seed",
|
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.\"}]",
|
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}]",
|
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.",
|
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",
|
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||||
|
SponsorsVisible = true,
|
||||||
TwitchAuthManagedByDatabase = false,
|
TwitchAuthManagedByDatabase = false,
|
||||||
TwitchClientId = "",
|
TwitchClientId = "",
|
||||||
TwitchClientSecret = "",
|
TwitchClientSecret = "",
|
||||||
TwitchRedirectUri = "",
|
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 =>
|
modelBuilder.Entity("Backend.Domain.TeamMember", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1408,6 +1673,28 @@ namespace Backend.Migrations
|
|||||||
b.Navigation("Season");
|
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 =>
|
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Backend.Domain.Season", "Season")
|
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()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||||
|
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
|
||||||
import { privacyContentToHtml } from '../lib/privacyContent'
|
import { privacyContentToHtml } from '../lib/privacyContent'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useAwardsStore } from '../stores/awards'
|
import { useAwardsStore } from '../stores/awards'
|
||||||
@@ -13,6 +14,8 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const awardsStore = useAwardsStore()
|
const awardsStore = useAwardsStore()
|
||||||
|
|
||||||
|
useBodyScrollLock(() => awardsStore.loading)
|
||||||
|
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
twitchUserId: 'jayuhime_viewer',
|
twitchUserId: 'jayuhime_viewer',
|
||||||
displayName: 'Jayuhime',
|
displayName: 'Jayuhime',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<div class="space-y-4">
|
||||||
<label class="block space-y-2">
|
<label class="block space-y-2">
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
<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>
|
<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)" />
|
<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>
|
</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>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||||
@@ -52,9 +116,17 @@ defineProps<{
|
|||||||
displayName: string
|
displayName: string
|
||||||
channelSlug: string
|
channelSlug: string
|
||||||
platform: string
|
platform: string
|
||||||
|
acceptanceStatus: string
|
||||||
|
acceptanceNote: string
|
||||||
|
clipCompilationUrl: string
|
||||||
|
clipCompilationTitle: string
|
||||||
|
clipCompilationPlatform: string
|
||||||
|
clipEmbedStatus: string
|
||||||
}
|
}
|
||||||
categoryOptions: Array<{ label: string; value: number }>
|
categoryOptions: Array<{ label: string; value: number }>
|
||||||
candidatePlatformOptions: SocialIconOption[]
|
candidatePlatformOptions: SocialIconOption[]
|
||||||
|
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
||||||
|
clipEmbedStatusOptions: Array<{ label: string; value: string }>
|
||||||
selectedPlatformValue: string
|
selectedPlatformValue: string
|
||||||
canSave: boolean
|
canSave: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
@@ -67,6 +139,12 @@ defineEmits<{
|
|||||||
'update:displayName': [value: string]
|
'update:displayName': [value: string]
|
||||||
'update:channelSlug': [value: string]
|
'update:channelSlug': [value: string]
|
||||||
'update:platform': [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]
|
'platform-selection': [value: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@
|
|||||||
:options="categoryFilterOptions"
|
:options="categoryFilterOptions"
|
||||||
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
|
@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
|
<X class="h-4 w-4" /> Filter
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="gap-2" @click="$emit('open-create')">
|
<Button class="gap-2" @click="$emit('open-create')">
|
||||||
@@ -33,12 +38,15 @@ import NativeSelect from '../ui/NativeSelect.vue'
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
search: string
|
search: string
|
||||||
categoryFilter: number | null
|
categoryFilter: number | null
|
||||||
|
readinessFilter: string
|
||||||
categoryFilterOptions: Array<{ label: string; value: number | null }>
|
categoryFilterOptions: Array<{ label: string; value: number | null }>
|
||||||
|
readinessFilterOptions: Array<{ label: string; value: string }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'update:search': [value: string]
|
'update:search': [value: string]
|
||||||
'update:categoryFilter': [value: number | null]
|
'update:categoryFilter': [value: number | null]
|
||||||
|
'update:readinessFilter': [value: string]
|
||||||
'clear-filters': []
|
'clear-filters': []
|
||||||
'open-create': []
|
'open-create': []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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>Kandidat</span>
|
||||||
<span>Kategorie</span>
|
<span>Kategorie</span>
|
||||||
|
<span>Annahme</span>
|
||||||
|
<span>Clip-Compilation</span>
|
||||||
<span>Plattform</span>
|
<span>Plattform</span>
|
||||||
<span class="text-right">Aktionen</span>
|
<span class="text-right">Aktionen</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="candidate in pagedCandidates"
|
v-for="candidate in pagedCandidates"
|
||||||
:key="candidate.id"
|
: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">
|
<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">
|
<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">
|
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||||
Mögliches Duplikat in dieser Kategorie
|
Mögliches Duplikat in dieser Kategorie
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -30,6 +40,31 @@
|
|||||||
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
|
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -71,7 +106,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { AdminCandidateItem } from '../../types/awards'
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
@@ -87,6 +122,8 @@ const props = defineProps<{
|
|||||||
rangeEnd: number
|
rangeEnd: number
|
||||||
categoryLabelMap: Record<number, string>
|
categoryLabelMap: Record<number, string>
|
||||||
duplicateCandidateKeys: Map<string, number>
|
duplicateCandidateKeys: Map<string, number>
|
||||||
|
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
|
||||||
|
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -100,4 +137,22 @@ function isDuplicate(candidate: AdminCandidateItem) {
|
|||||||
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|
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
|
|| (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>
|
</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>
|
<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" />
|
<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>
|
||||||
|
<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>
|
||||||
<div class="mt-7 space-y-5">
|
<div class="mt-7 space-y-5">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -75,6 +79,20 @@
|
|||||||
min-height-class="min-h-[260px]"
|
min-height-class="min-h-[260px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
@@ -87,7 +105,7 @@ import Card from '../ui/Card.vue'
|
|||||||
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||||
import type { AdminContentForm } from './adminContentTypes'
|
import type { AdminContentForm } from './adminContentTypes'
|
||||||
|
|
||||||
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
|
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors' | 'showacts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
form: AdminContentForm
|
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 {
|
const {
|
||||||
reviewSaving,
|
reviewSaving,
|
||||||
|
blacklistSaving,
|
||||||
adminMessage,
|
adminMessage,
|
||||||
adminError,
|
adminError,
|
||||||
reviewForms,
|
reviewForms,
|
||||||
@@ -39,6 +40,7 @@ const {
|
|||||||
canApproveSelected,
|
canApproveSelected,
|
||||||
approveNomination,
|
approveNomination,
|
||||||
rejectNomination,
|
rejectNomination,
|
||||||
|
addStreamUrlToBlacklist,
|
||||||
selectedPlatformValue,
|
selectedPlatformValue,
|
||||||
handlePlatformSelection,
|
handlePlatformSelection,
|
||||||
extractNominationStreamUrl,
|
extractNominationStreamUrl,
|
||||||
@@ -153,10 +155,12 @@ onBeforeUnmount(() => {
|
|||||||
:signal-summary="selectedNominationSignalSummary"
|
:signal-summary="selectedNominationSignalSummary"
|
||||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||||
:can-approve-selected="canApproveSelected"
|
:can-approve-selected="canApproveSelected"
|
||||||
|
:blacklist-saving="blacklistSaving"
|
||||||
:selected-platform-value="selectedPlatformValue"
|
:selected-platform-value="selectedPlatformValue"
|
||||||
@platform-change="handlePlatformSelection"
|
@platform-change="handlePlatformSelection"
|
||||||
@approve="approveNomination"
|
@approve="approveNomination"
|
||||||
@reject="rejectNomination"
|
@reject="rejectNomination"
|
||||||
|
@blacklist-link="addStreamUrlToBlacklist"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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 Button from '../ui/Button.vue'
|
||||||
import NativeSelect from '../ui/NativeSelect.vue'
|
import NativeSelect from '../ui/NativeSelect.vue'
|
||||||
@@ -24,6 +24,7 @@ defineProps<{
|
|||||||
} | null
|
} | null
|
||||||
streamUrl?: string
|
streamUrl?: string
|
||||||
canApproveSelected: boolean
|
canApproveSelected: boolean
|
||||||
|
blacklistSaving: boolean
|
||||||
selectedPlatformValue: (platform: string) => string
|
selectedPlatformValue: (platform: string) => string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const emit = defineEmits<{
|
|||||||
'platform-change': [value: string]
|
'platform-change': [value: string]
|
||||||
approve: [nominationId: number]
|
approve: [nominationId: number]
|
||||||
reject: [nominationId: number]
|
reject: [nominationId: number]
|
||||||
|
'blacklist-link': [streamUrl: string]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</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">
|
<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>
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
|
||||||
<a
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
:href="streamUrl"
|
<a
|
||||||
target="_blank"
|
:href="streamUrl"
|
||||||
rel="noopener"
|
target="_blank"
|
||||||
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"
|
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>
|
<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>
|
||||||
|
|
||||||
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
|
<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
|
contactContent: string
|
||||||
sponsorsUrl: string
|
sponsorsUrl: string
|
||||||
sponsorsContent: string
|
sponsorsContent: string
|
||||||
|
showactsUrl: string
|
||||||
|
showactsContent: string
|
||||||
socialLinks: SocialLinkForm[]
|
socialLinks: SocialLinkForm[]
|
||||||
faq: FaqFormItem[]
|
faq: FaqFormItem[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,45 @@ import { computed, reactive, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
import type { AdminCandidateItem } from '../../types/awards'
|
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
||||||
|
|
||||||
interface CandidateForm {
|
interface CandidateForm {
|
||||||
categoryId: number
|
categoryId: number
|
||||||
displayName: string
|
displayName: string
|
||||||
channelSlug: string
|
channelSlug: string
|
||||||
platform: string
|
platform: string
|
||||||
|
acceptanceStatus: string
|
||||||
|
acceptanceNote: string
|
||||||
|
clipCompilationUrl: string
|
||||||
|
clipCompilationTitle: string
|
||||||
|
clipCompilationPlatform: string
|
||||||
|
clipEmbedStatus: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = 10
|
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() {
|
export function useAdminCandidateManager() {
|
||||||
const store = useAwardsStore()
|
const store = useAwardsStore()
|
||||||
@@ -21,13 +50,26 @@ export function useAdminCandidateManager() {
|
|||||||
const adminError = ref('')
|
const adminError = ref('')
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const categoryFilter = ref<number | null>(null)
|
const categoryFilter = ref<number | null>(null)
|
||||||
|
const readinessFilter = ref('all')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const editingId = ref<number | 'new' | null>(null)
|
const editingId = ref<number | 'new' | null>(null)
|
||||||
const candidateToDelete = ref<AdminCandidateItem | 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 seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const workflowRules = computed(() => store.adminWorkflowRules.rules)
|
||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
const categoryOptions = computed(() =>
|
const categoryOptions = computed(() =>
|
||||||
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
|
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
|
||||||
@@ -51,6 +93,23 @@ export function useAdminCandidateManager() {
|
|||||||
const duplicateCandidateCount = computed(() =>
|
const duplicateCandidateCount = computed(() =>
|
||||||
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
|
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 filteredCandidates = computed(() => {
|
||||||
const query = search.value.trim().toLowerCase()
|
const query = search.value.trim().toLowerCase()
|
||||||
let list = seasonDetail.value.candidates
|
let list = seasonDetail.value.candidates
|
||||||
@@ -59,6 +118,20 @@ export function useAdminCandidateManager() {
|
|||||||
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
|
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) {
|
if (query) {
|
||||||
list = list.filter((candidate) =>
|
list = list.filter((candidate) =>
|
||||||
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
|
[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 rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
|
||||||
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
|
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
|
||||||
const canSave = computed(() =>
|
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 candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||||
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
|
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
|
page.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,6 +177,16 @@ export function useAdminCandidateManager() {
|
|||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
search.value = ''
|
search.value = ''
|
||||||
categoryFilter.value = null
|
categoryFilter.value = null
|
||||||
|
readinessFilter.value = 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPreparationForm() {
|
||||||
|
form.acceptanceStatus = 'open'
|
||||||
|
form.acceptanceNote = ''
|
||||||
|
form.clipCompilationUrl = ''
|
||||||
|
form.clipCompilationTitle = ''
|
||||||
|
form.clipCompilationPlatform = ''
|
||||||
|
form.clipEmbedStatus = 'unchecked'
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -107,6 +197,7 @@ export function useAdminCandidateManager() {
|
|||||||
form.displayName = ''
|
form.displayName = ''
|
||||||
form.channelSlug = ''
|
form.channelSlug = ''
|
||||||
form.platform = 'Twitch'
|
form.platform = 'Twitch'
|
||||||
|
resetPreparationForm()
|
||||||
modalOpen.value = true
|
modalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +209,12 @@ export function useAdminCandidateManager() {
|
|||||||
form.displayName = candidate.displayName
|
form.displayName = candidate.displayName
|
||||||
form.channelSlug = candidate.channelSlug
|
form.channelSlug = candidate.channelSlug
|
||||||
form.platform = candidate.platform
|
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
|
modalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +240,10 @@ export function useAdminCandidateManager() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingId.value === 'new') {
|
if (editingId.value === 'new') {
|
||||||
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
|
await store.createAdminCandidate(selectedSeasonId.value, buildCandidatePayload())
|
||||||
adminMessage.value = `„${form.displayName}" wurde angelegt.`
|
adminMessage.value = `„${form.displayName}" wurde angelegt.`
|
||||||
} else if (typeof editingId.value === 'number') {
|
} 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.`
|
adminMessage.value = `„${form.displayName}" wurde gespeichert.`
|
||||||
}
|
}
|
||||||
modalOpen.value = false
|
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() {
|
async function confirmDelete() {
|
||||||
if (!candidateToDelete.value || !selectedSeasonId.value) {
|
if (!candidateToDelete.value || !selectedSeasonId.value) {
|
||||||
return
|
return
|
||||||
@@ -184,12 +297,17 @@ export function useAdminCandidateManager() {
|
|||||||
adminError,
|
adminError,
|
||||||
search,
|
search,
|
||||||
categoryFilter,
|
categoryFilter,
|
||||||
|
readinessFilter,
|
||||||
page,
|
page,
|
||||||
categoryOptions,
|
categoryOptions,
|
||||||
categoryFilterOptions,
|
categoryFilterOptions,
|
||||||
categoryLabelMap,
|
categoryLabelMap,
|
||||||
duplicateCandidateKeys,
|
duplicateCandidateKeys,
|
||||||
|
candidateWorkflowNotices,
|
||||||
duplicateCandidateCount,
|
duplicateCandidateCount,
|
||||||
|
acceptedCandidateCount,
|
||||||
|
clipReadyCount,
|
||||||
|
actionNeededCount,
|
||||||
filteredCandidates,
|
filteredCandidates,
|
||||||
pagedCandidates,
|
pagedCandidates,
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -201,6 +319,9 @@ export function useAdminCandidateManager() {
|
|||||||
canSave,
|
canSave,
|
||||||
candidatePlatformOptions,
|
candidatePlatformOptions,
|
||||||
selectedPlatformValue,
|
selectedPlatformValue,
|
||||||
|
acceptanceStatusOptions,
|
||||||
|
clipEmbedStatusOptions,
|
||||||
|
readinessFilterOptions,
|
||||||
candidateToDelete,
|
candidateToDelete,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
openCreate,
|
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') {
|
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
|
||||||
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
|
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
|
||||||
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
|
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
|
||||||
@@ -220,3 +394,19 @@ function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys:
|
|||||||
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|
||||||
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 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 seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
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 submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
|
||||||
const categories = computed(() => seasonDetail.value.categories ?? [])
|
const categories = computed(() => seasonDetail.value.categories ?? [])
|
||||||
const categoryName = computed(() =>
|
const categoryName = computed(() =>
|
||||||
@@ -188,6 +205,11 @@ export function useAdminClipManager() {
|
|||||||
adminError,
|
adminError,
|
||||||
clipToDelete,
|
clipToDelete,
|
||||||
reviewNotes,
|
reviewNotes,
|
||||||
|
clipSubmissionsEnabled,
|
||||||
|
clipReviewEnabled,
|
||||||
|
clipDisabledMessage,
|
||||||
|
clipWorkflowStatusLabel,
|
||||||
|
clipWorkflowStatusClass,
|
||||||
submissions,
|
submissions,
|
||||||
categoryName,
|
categoryName,
|
||||||
clips,
|
clips,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ function createEmptyForm(): AdminContentForm {
|
|||||||
contactContent: '',
|
contactContent: '',
|
||||||
sponsorsUrl: '',
|
sponsorsUrl: '',
|
||||||
sponsorsContent: '',
|
sponsorsContent: '',
|
||||||
|
showactsUrl: '',
|
||||||
|
showactsContent: '',
|
||||||
socialLinks: [],
|
socialLinks: [],
|
||||||
faq: [],
|
faq: [],
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,8 @@ export function useAdminContentManager() {
|
|||||||
form.contactContent = settings.contactContent
|
form.contactContent = settings.contactContent
|
||||||
form.sponsorsUrl = settings.sponsorsUrl
|
form.sponsorsUrl = settings.sponsorsUrl
|
||||||
form.sponsorsContent = settings.sponsorsContent
|
form.sponsorsContent = settings.sponsorsContent
|
||||||
|
form.showactsUrl = settings.showactsUrl
|
||||||
|
form.showactsContent = settings.showactsContent
|
||||||
form.socialLinks = settings.socialLinks.map((item) => ({
|
form.socialLinks = settings.socialLinks.map((item) => ({
|
||||||
label: item.label ?? '',
|
label: item.label ?? '',
|
||||||
platform: item.platform ?? '',
|
platform: item.platform ?? '',
|
||||||
@@ -249,6 +253,8 @@ export function useAdminContentManager() {
|
|||||||
contactContent: privacyContentForStorage(form.contactContent),
|
contactContent: privacyContentForStorage(form.contactContent),
|
||||||
sponsorsUrl: form.sponsorsUrl,
|
sponsorsUrl: form.sponsorsUrl,
|
||||||
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
|
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
|
||||||
|
showactsUrl: form.showactsUrl,
|
||||||
|
showactsContent: privacyContentForStorage(form.showactsContent),
|
||||||
socialLinks: form.socialLinks
|
socialLinks: form.socialLinks
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: item.label.trim(),
|
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 store = useAwardsStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const reviewSaving = ref<number | null>(null)
|
const reviewSaving = ref<number | null>(null)
|
||||||
|
const blacklistSaving = ref(false)
|
||||||
const adminMessage = ref('')
|
const adminMessage = ref('')
|
||||||
const adminError = ref('')
|
const adminError = ref('')
|
||||||
const reviewFilter = 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) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
const target = event.target as Element
|
const target = event.target as Element
|
||||||
if (
|
if (
|
||||||
@@ -280,6 +299,7 @@ export function useAdminReviewsManager() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
reviewSaving,
|
reviewSaving,
|
||||||
|
blacklistSaving,
|
||||||
adminMessage,
|
adminMessage,
|
||||||
adminError,
|
adminError,
|
||||||
reviewForms,
|
reviewForms,
|
||||||
@@ -299,6 +319,7 @@ export function useAdminReviewsManager() {
|
|||||||
canApproveSelected,
|
canApproveSelected,
|
||||||
approveNomination,
|
approveNomination,
|
||||||
rejectNomination,
|
rejectNomination,
|
||||||
|
addStreamUrlToBlacklist,
|
||||||
selectedPlatformValue,
|
selectedPlatformValue,
|
||||||
handlePlatformSelection,
|
handlePlatformSelection,
|
||||||
extractNominationStreamUrl,
|
extractNominationStreamUrl,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function useAdminSettingsOverview() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
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 pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
|
||||||
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
|
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
|
||||||
const healthLoadedLabel = computed(() =>
|
const healthLoadedLabel = computed(() =>
|
||||||
@@ -29,12 +30,14 @@ export function useAdminSettingsOverview() {
|
|||||||
siteSettings.value.imprintUrl,
|
siteSettings.value.imprintUrl,
|
||||||
siteSettings.value.contactUrl,
|
siteSettings.value.contactUrl,
|
||||||
siteSettings.value.sponsorsUrl,
|
siteSettings.value.sponsorsUrl,
|
||||||
|
siteSettings.value.showactsUrl,
|
||||||
siteSettings.value.newsletterUrl,
|
siteSettings.value.newsletterUrl,
|
||||||
].filter((url) => url.trim()).length)
|
].filter((url) => url.trim()).length)
|
||||||
const configuredFooterPages = computed(() => [
|
const configuredFooterPages = computed(() => [
|
||||||
siteSettings.value.imprintContent,
|
siteSettings.value.imprintContent,
|
||||||
siteSettings.value.contactContent,
|
siteSettings.value.contactContent,
|
||||||
siteSettings.value.sponsorsContent,
|
siteSettings.value.sponsorsContent,
|
||||||
|
siteSettings.value.showactsContent,
|
||||||
].filter((content) => content.trim()).length)
|
].filter((content) => content.trim()).length)
|
||||||
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
|
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -62,8 +65,8 @@ export function useAdminSettingsOverview() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Footer Links',
|
label: 'Footer Links',
|
||||||
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
|
value: configuredFooterLinks.value === 5 && configuredFooterPages.value === 4,
|
||||||
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
|
note: `${configuredFooterLinks.value} von 5 Link-Zielen, ${configuredFooterPages.value} von 4 Footer-Seiten gepflegt`,
|
||||||
icon: Link2,
|
icon: Link2,
|
||||||
to: '/admin/content',
|
to: '/admin/content',
|
||||||
},
|
},
|
||||||
@@ -143,7 +146,15 @@ export function useAdminSettingsOverview() {
|
|||||||
to: '/admin/categories',
|
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,
|
state: pendingClips.value > 0,
|
||||||
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
|
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
|
||||||
to: '/admin/clips',
|
to: '/admin/clips',
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import { computed, reactive, ref, watch } from 'vue'
|
|||||||
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
|
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
|
||||||
|
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
||||||
|
|
||||||
const allFilter = 'all'
|
const allFilter = 'all'
|
||||||
|
const maxWinnerPlacementsRuleKey = 'max_winner_placements'
|
||||||
|
const winnerRequiresClipRuleKey = 'winner_requires_clip'
|
||||||
|
|
||||||
|
type WinnerRuleNotice = {
|
||||||
|
mode: 'warn' | 'block'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export function useAdminWinnersManager() {
|
export function useAdminWinnersManager() {
|
||||||
const store = useAwardsStore()
|
const store = useAwardsStore()
|
||||||
@@ -16,7 +24,53 @@ export function useAdminWinnersManager() {
|
|||||||
const winnerSelections = reactive<Record<number, string>>({})
|
const winnerSelections = reactive<Record<number, string>>({})
|
||||||
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
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 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(() =>
|
const resultRows = computed(() =>
|
||||||
seasonDetail.value.categories
|
seasonDetail.value.categories
|
||||||
@@ -125,6 +179,13 @@ export function useAdminWinnersManager() {
|
|||||||
async function saveWinner(categoryId: number) {
|
async function saveWinner(categoryId: number) {
|
||||||
const candidateId = Number(winnerSelections[categoryId])
|
const candidateId = Number(winnerSelections[categoryId])
|
||||||
if (!candidateId || !seasonDetail.value.id) return
|
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
|
savingResultForCategory.value = categoryId
|
||||||
adminMessage.value = ''
|
adminMessage.value = ''
|
||||||
adminError.value = ''
|
adminError.value = ''
|
||||||
@@ -167,5 +228,6 @@ export function useAdminWinnersManager() {
|
|||||||
winnerSelections,
|
winnerSelections,
|
||||||
clearWinner,
|
clearWinner,
|
||||||
saveWinner,
|
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;
|
margin: -4px auto 12px;
|
||||||
padding: 6px 0 10px;
|
padding: 6px 0 10px;
|
||||||
color: #fff8fd;
|
color: #fff8fd;
|
||||||
font-family: 'Cormorant Garamond', serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: clamp(42px, 5.4vw, 66px);
|
font-size: clamp(36px, 4.8vw, 58px);
|
||||||
line-height: 1.08;
|
line-height: 1.12;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-wrap: balance;
|
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 class="flex shrink-0 items-center justify-between gap-4 border-b border-[#f1ecfb] px-8 py-5">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Rechtliches</p>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,7 +101,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Mein Profil</p>
|
<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 }}
|
@{{ profileHandle }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import HomeStarField from './HomeStarField.vue'
|
||||||
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
|
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -13,19 +14,25 @@ const props = defineProps<{
|
|||||||
winnerPlatformKey: (url: string) => string
|
winnerPlatformKey: (url: string) => string
|
||||||
winnerPlatformLabel: (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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="props.archiveModalOpen">
|
<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-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>
|
<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;">
|
<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>
|
<HomeStarField :count="13" variant="header" />
|
||||||
<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>
|
|
||||||
<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>
|
<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>
|
||||||
<div class="home-archive-modal__body" style="display:grid;grid-template-columns:220px minmax(0,1fr);min-height:0;flex:1;">
|
<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;">
|
<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
|
<article
|
||||||
v-for="winner in props.selectedArchive.winners"
|
v-for="winner in props.selectedArchive.winners"
|
||||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
: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="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-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-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="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>
|
</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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
<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="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>
|
<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>
|
<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>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
|
<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>
|
<template>
|
||||||
<section class="home-stream-band" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);">
|
<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>
|
<HomeStarField :count="13" variant="light" />
|
||||||
<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>
|
|
||||||
<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__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 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);">
|
<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>
|
<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-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;">
|
<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="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>
|
<span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,.4);"></span>
|
||||||
@@ -46,6 +44,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import HomeStarField from './HomeStarField.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
publicStreamUrl: string
|
publicStreamUrl: string
|
||||||
streamEyebrow: string
|
streamEyebrow: string
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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 HomeSelectDropdown from './HomeSelectDropdown.vue'
|
||||||
|
import HomeStarField from './HomeStarField.vue'
|
||||||
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
|
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
|
||||||
|
import HomeWizardFooter from './HomeWizardFooter.vue'
|
||||||
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
|
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
|
||||||
|
import type { HomeNominationSubmitContext } from './homeLandingTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modalOpen: boolean
|
modalOpen: boolean
|
||||||
@@ -30,8 +36,10 @@ const props = defineProps<{
|
|||||||
voteCount: number
|
voteCount: number
|
||||||
totalCats: number
|
totalCats: number
|
||||||
canSubmitVote: boolean
|
canSubmitVote: boolean
|
||||||
|
savedVoteActive: boolean
|
||||||
|
activeCatIndex: number
|
||||||
submitVote: () => Promise<void> | void
|
submitVote: () => Promise<void> | void
|
||||||
submitNomination: () => Promise<void> | void
|
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
|
||||||
submitting: boolean
|
submitting: boolean
|
||||||
onClipCatChange: (event: Event) => void
|
onClipCatChange: (event: Event) => void
|
||||||
catOptions: HomeSelectionOption[]
|
catOptions: HomeSelectionOption[]
|
||||||
@@ -47,10 +55,14 @@ const props = defineProps<{
|
|||||||
const nominationCatValue = ref(0)
|
const nominationCatValue = ref(0)
|
||||||
const clipCatValue = ref(0)
|
const clipCatValue = ref(0)
|
||||||
const clipNomQuery = ref('')
|
const clipNomQuery = ref('')
|
||||||
|
const nominationDrafts = ref<Record<number, string[]>>({})
|
||||||
|
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
|
||||||
|
const nominationReviewMode = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.catOptions,
|
() => props.catOptions,
|
||||||
(options) => {
|
(options) => {
|
||||||
|
syncNominationDrafts(options)
|
||||||
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
|
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
|
||||||
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
|
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
|
||||||
if (nextClipCat !== clipCatValue.value) {
|
if (nextClipCat !== clipCatValue.value) {
|
||||||
@@ -61,6 +73,16 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modalOpen,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
nominationReviewMode.value = false
|
||||||
|
nominationCatValue.value = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
|
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
|
||||||
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
|
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
|
||||||
}
|
}
|
||||||
@@ -74,20 +96,213 @@ function handleClipCategoryChange(value: number) {
|
|||||||
function notifyClipCategoryChange(value: number) {
|
function notifyClipCategoryChange(value: number) {
|
||||||
props.onClipCatChange({ target: { value: String(value) } } as unknown as Event)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="props.modalOpen">
|
<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-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);">
|
<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: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>
|
<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">
|
<template v-if="props.submitted">
|
||||||
<div class="home-modal__success" style="padding:56px 40px;text-align:center;">
|
<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);">
|
<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>
|
<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>
|
</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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -104,7 +319,7 @@ function notifyClipCategoryChange(value: number) {
|
|||||||
<template v-if="props.isShow">
|
<template v-if="props.isShow">
|
||||||
<div class="home-modal__show" style="padding:38px 40px 40px;">
|
<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>
|
<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>
|
<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;">
|
<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;" />
|
<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.isPicker">
|
||||||
<template v-if="props.nominationPhase && !props.isVote">
|
<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__nomination-submit home-wizard-shell">
|
||||||
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
<div class="home-modal__picker-header" style="padding:30px 36px 22px;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>
|
<HomeStarField :count="13" variant="header" />
|
||||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
|
<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>
|
||||||
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
|
<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>
|
||||||
<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;">
|
<div class="home-wizard-layout">
|
||||||
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
|
<HomeCategoryProgressRail
|
||||||
<div>
|
label="Kategorien"
|
||||||
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
|
:items="nominationRailItems"
|
||||||
<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>
|
:active-id="nominationCatValue"
|
||||||
</div>
|
:on-select="selectNominationCategory"
|
||||||
<div>
|
/>
|
||||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
|
<main class="home-wizard-content">
|
||||||
<HomeSelectDropdown
|
<HomeNominationReviewPane
|
||||||
v-model="nominationCatValue"
|
v-if="nominationReviewMode"
|
||||||
data-ref="nominationCatRef"
|
:items="nominationReviewItems"
|
||||||
label="Kategorie fuer Nominierung auswaehlen"
|
:total-links="nominationTotalLinks"
|
||||||
:options="props.catOptions"
|
:total-categories="props.catOptions.length"
|
||||||
/>
|
:on-edit-category="editNominationCategory"
|
||||||
</div>
|
/>
|
||||||
<div>
|
<HomeNominationWizardPane
|
||||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
|
v-else
|
||||||
<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;" />
|
:category-name="activeNominationCategory ? stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||||
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Offizieller Kanal- oder Stream-Link der Person.</p>
|
:links="activeNominationLinks"
|
||||||
</div>
|
:errors="activeNominationErrors"
|
||||||
<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);">
|
:visible-link-count="activeNominationVisibleLinkCount"
|
||||||
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
|
:remaining-link-count="activeNominationRemainingLinks"
|
||||||
</button>
|
:on-link-input="onNominationLinkInput"
|
||||||
</section>
|
:on-add-link-field="addNominationLinkField"
|
||||||
|
:on-remove-link-field="removeNominationLinkField"
|
||||||
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fffafd;border:1px solid #f4d8e9;">
|
/>
|
||||||
<div>
|
</main>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div style="display:flex;flex-direction:column;min-height:0;">
|
<div class="home-vote-shell">
|
||||||
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;">
|
<div class="home-modal__picker-header" style="padding:30px 36px 22px;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>
|
<HomeStarField :count="13" variant="header" />
|
||||||
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
|
<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>
|
</div>
|
||||||
<HomeVotingPickerPane
|
<HomeVotingPickerPane
|
||||||
:cat-list="props.catList"
|
:cat-list="props.catList"
|
||||||
|
:active-cat-index="props.activeCatIndex"
|
||||||
:active-cat-name="props.activeCatName"
|
:active-cat-name="props.activeCatName"
|
||||||
:noms="props.noms"
|
:noms="props.noms"
|
||||||
:vote-count="props.voteCount"
|
:vote-count="props.voteCount"
|
||||||
:total-cats="props.totalCats"
|
:total-cats="props.totalCats"
|
||||||
:can-submit-vote="props.canSubmitVote"
|
:can-submit-vote="props.canSubmitVote"
|
||||||
|
:saved-vote-active="props.savedVoteActive"
|
||||||
:submit-vote="props.submitVote"
|
:submit-vote="props.submitVote"
|
||||||
:submitting="props.submitting"
|
:submitting="props.submitting"
|
||||||
|
:readonly-mode="!props.isVote"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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" 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 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>
|
<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>
|
<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>
|
||||||
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
|
<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 CinematicStarLoader from '../CinematicStarLoader.vue'
|
||||||
import HomeCategoriesSection from './HomeCategoriesSection.vue'
|
import HomeCategoriesSection from './HomeCategoriesSection.vue'
|
||||||
import HomeHeroShell from './HomeHeroShell.vue'
|
import HomeHeroShell from './HomeHeroShell.vue'
|
||||||
|
import HomeExtrasSection from './HomeExtrasSection.vue'
|
||||||
import HomeLandingModals from './HomeLandingModals.vue'
|
import HomeLandingModals from './HomeLandingModals.vue'
|
||||||
import HomeParticipationSection from './HomeParticipationSection.vue'
|
import HomeParticipationSection from './HomeParticipationSection.vue'
|
||||||
|
import HomeStickyCountdownPill from './HomeStickyCountdownPill.vue'
|
||||||
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
|
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
|
||||||
import HomeTimelineSection from './HomeTimelineSection.vue'
|
import HomeTimelineSection from './HomeTimelineSection.vue'
|
||||||
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
|
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
|
||||||
@@ -50,6 +52,7 @@ const {
|
|||||||
voteCount,
|
voteCount,
|
||||||
totalCats,
|
totalCats,
|
||||||
canSubmitVote,
|
canSubmitVote,
|
||||||
|
savedVoteActive,
|
||||||
activeCatName,
|
activeCatName,
|
||||||
phaseCardTitle,
|
phaseCardTitle,
|
||||||
phaseCardDescription,
|
phaseCardDescription,
|
||||||
@@ -89,6 +92,7 @@ const {
|
|||||||
archiveYears,
|
archiveYears,
|
||||||
selectedArchive,
|
selectedArchive,
|
||||||
winnerShowcase,
|
winnerShowcase,
|
||||||
|
activeCat,
|
||||||
submitted,
|
submitted,
|
||||||
submitting,
|
submitting,
|
||||||
formError,
|
formError,
|
||||||
@@ -143,13 +147,13 @@ const {
|
|||||||
} = useHomeLandingState()
|
} = useHomeLandingState()
|
||||||
|
|
||||||
const previewPhaseButtons = [
|
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: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
|
||||||
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
|
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
|
||||||
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
|
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({
|
const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmit } = useHomeLandingViewEffects({
|
||||||
router,
|
router,
|
||||||
store,
|
store,
|
||||||
authStore,
|
authStore,
|
||||||
@@ -165,7 +169,6 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
preparationPhase,
|
preparationPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
initializeHomeInteractions,
|
initializeHomeInteractions,
|
||||||
submitNomination,
|
|
||||||
submitClip,
|
submitClip,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -186,6 +189,13 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<div :style="rootStyle" class="home-landing">
|
<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 v-if="isAdmin" class="home-demo-preview" aria-label="Demo Phasen-Vorschau">
|
||||||
<div class="home-demo-preview__label">
|
<div class="home-demo-preview__label">
|
||||||
<span>✦</span>
|
<span>✦</span>
|
||||||
@@ -282,6 +292,8 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
:on-section-action="onSectionAction"
|
:on-section-action="onSectionAction"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<HomeExtrasSection />
|
||||||
|
|
||||||
<HomeSupportFooterSection
|
<HomeSupportFooterSection
|
||||||
:community-social-links="communitySocialLinks"
|
:community-social-links="communitySocialLinks"
|
||||||
:site-content="siteContent"
|
:site-content="siteContent"
|
||||||
@@ -314,13 +326,15 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
:picker-title="pickerTitle"
|
:picker-title="pickerTitle"
|
||||||
:picker-subtitle="pickerSubtitle"
|
:picker-subtitle="pickerSubtitle"
|
||||||
:cat-list="catList"
|
:cat-list="catList"
|
||||||
|
:active-cat-index="activeCat"
|
||||||
:active-cat-name="activeCatName"
|
:active-cat-name="activeCatName"
|
||||||
:noms="noms"
|
:noms="noms"
|
||||||
:vote-count="voteCount"
|
:vote-count="voteCount"
|
||||||
:total-cats="totalCats"
|
:total-cats="totalCats"
|
||||||
:can-submit-vote="canSubmitVote"
|
:can-submit-vote="canSubmitVote"
|
||||||
|
:saved-vote-active="savedVoteActive"
|
||||||
:submit-vote="submitVote"
|
:submit-vote="submitVote"
|
||||||
:submit-nomination="handleNominationSubmit"
|
:submit-nomination="submitNomination"
|
||||||
:submitting="submitting"
|
:submitting="submitting"
|
||||||
:on-clip-cat-change="onClipCatChange"
|
:on-clip-cat-change="onClipCatChange"
|
||||||
:cat-options="catOptions"
|
:cat-options="catOptions"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
HomeSelectedArchive,
|
HomeSelectedArchive,
|
||||||
HomeSelectionOption,
|
HomeSelectionOption,
|
||||||
} from './homeModalTypes'
|
} from './homeModalTypes'
|
||||||
|
import type { HomeNominationSubmitContext } from './homeLandingTypes'
|
||||||
import type { AuthSession } from '../../types/awards'
|
import type { AuthSession } from '../../types/awards'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -31,13 +32,15 @@ defineProps<{
|
|||||||
pickerTitle: string
|
pickerTitle: string
|
||||||
pickerSubtitle: string
|
pickerSubtitle: string
|
||||||
catList: HomeCategoryListItem[]
|
catList: HomeCategoryListItem[]
|
||||||
|
activeCatIndex: number
|
||||||
activeCatName: string
|
activeCatName: string
|
||||||
noms: HomeNomineeListItem[]
|
noms: HomeNomineeListItem[]
|
||||||
voteCount: number
|
voteCount: number
|
||||||
totalCats: number
|
totalCats: number
|
||||||
canSubmitVote: boolean
|
canSubmitVote: boolean
|
||||||
|
savedVoteActive: boolean
|
||||||
submitVote: () => Promise<void> | void
|
submitVote: () => Promise<void> | void
|
||||||
submitNomination: () => Promise<void> | void
|
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
|
||||||
submitting: boolean
|
submitting: boolean
|
||||||
onClipCatChange: (event: Event) => void
|
onClipCatChange: (event: Event) => void
|
||||||
catOptions: HomeSelectionOption[]
|
catOptions: HomeSelectionOption[]
|
||||||
@@ -100,11 +103,13 @@ defineProps<{
|
|||||||
:picker-title="pickerTitle"
|
:picker-title="pickerTitle"
|
||||||
:picker-subtitle="pickerSubtitle"
|
:picker-subtitle="pickerSubtitle"
|
||||||
:cat-list="catList"
|
:cat-list="catList"
|
||||||
|
:active-cat-index="activeCatIndex"
|
||||||
:active-cat-name="activeCatName"
|
:active-cat-name="activeCatName"
|
||||||
:noms="noms"
|
:noms="noms"
|
||||||
:vote-count="voteCount"
|
:vote-count="voteCount"
|
||||||
:total-cats="totalCats"
|
:total-cats="totalCats"
|
||||||
:can-submit-vote="canSubmitVote"
|
:can-submit-vote="canSubmitVote"
|
||||||
|
:saved-vote-active="savedVoteActive"
|
||||||
:submit-vote="submitVote"
|
:submit-vote="submitVote"
|
||||||
:submit-nomination="submitNomination"
|
:submit-nomination="submitNomination"
|
||||||
:submitting="submitting"
|
: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">
|
<script setup lang="ts">
|
||||||
|
import HomeStarField from './HomeStarField.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sectionTitle: string
|
sectionTitle: string
|
||||||
sectionText: 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>
|
<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>
|
<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>
|
</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>
|
</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>
|
<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;">
|
<div style="flex:1;">
|
||||||
@@ -51,10 +53,8 @@ const props = defineProps<{
|
|||||||
</div>
|
</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);">
|
<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>
|
<HomeStarField :count="13" variant="light" />
|
||||||
<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>
|
<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>
|
||||||
<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>
|
|
||||||
<p style="font-size:18px;color:rgba(255,255,255,.92);max-width:540px;margin:0 auto 28px;">{{ props.sectionText }}</p>
|
<p style="font-size:18px;color:rgba(255,255,255,.92);max-width:540px;margin:0 auto 28px;">{{ props.sectionText }}</p>
|
||||||
<button
|
<button
|
||||||
v-if="props.sectionActionDisabled"
|
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 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" 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%;">
|
<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 & 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 & 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>
|
<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;">
|
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:26px;">
|
||||||
<a
|
<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));" />
|
<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>
|
||||||
<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;">
|
<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>
|
<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;">
|
<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>
|
<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;">
|
<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="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>
|
<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>
|
<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>
|
||||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
<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 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>
|
||||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</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>
|
</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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
|
<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="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>
|
<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>
|
<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>
|
</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>
|
<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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,115 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
import type { HomeCategoryListItem, HomeNomineeListItem } from './homeModalTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
catList: HomeCategoryListItem[]
|
catList: HomeCategoryListItem[]
|
||||||
|
activeCatIndex: number
|
||||||
activeCatName: string
|
activeCatName: string
|
||||||
noms: HomeNomineeListItem[]
|
noms: HomeNomineeListItem[]
|
||||||
voteCount: number
|
voteCount: number
|
||||||
totalCats: number
|
totalCats: number
|
||||||
canSubmitVote: boolean
|
canSubmitVote: boolean
|
||||||
|
savedVoteActive: boolean
|
||||||
submitVote: () => Promise<void> | void
|
submitVote: () => Promise<void> | void
|
||||||
submitting: boolean
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -18,35 +117,94 @@ const props = defineProps<{
|
|||||||
<aside class="home-vote-picker__rail">
|
<aside class="home-vote-picker__rail">
|
||||||
<div class="home-vote-picker__rail-label">Kategorien</div>
|
<div class="home-vote-picker__rail-label">Kategorien</div>
|
||||||
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
|
<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 :style="cat.iconStyle">{{ cat.icon }}</span>
|
||||||
<span>{{ cat.name }}</span>
|
<span>{{ cat.name }}</span>
|
||||||
<span :style="cat.checkStyle">✓</span>
|
<span v-if="!props.readonlyMode" :style="cat.checkStyle">✓</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</aside>
|
</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">
|
<header class="home-vote-picker__category-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="home-vote-picker__eyebrow">Kategorie</p>
|
|
||||||
<h4>{{ props.activeCatName }}</h4>
|
<h4>{{ props.activeCatName }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<span class="home-vote-picker__hint">Nominee ansehen, Clip prüfen, Favorit:in wählen.</span>
|
|
||||||
</header>
|
</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">
|
<div class="home-vote-picker__cards">
|
||||||
<template v-for="(nom, __idx) in props.noms" :key="nom.idx ?? __idx">
|
<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 }">
|
<a
|
||||||
<div class="home-vote-card__identity">
|
v-if="props.readonlyMode && nom.url"
|
||||||
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
|
class="home-nomination-summary-card home-nomination-summary-card--clickable"
|
||||||
<div class="home-vote-card__name-block">
|
:href="nom.url"
|
||||||
<div class="home-vote-card__name-row">
|
target="_blank"
|
||||||
<h5>{{ nom.name }}</h5>
|
rel="noopener noreferrer"
|
||||||
<span>{{ nom.platform }}</span>
|
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>
|
</div>
|
||||||
<p>{{ nom.handle }}</p>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="home-vote-card__clip" @click.stop>
|
<div class="home-vote-card__clip" @click.stop>
|
||||||
@@ -80,11 +238,6 @@ const props = defineProps<{
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -92,17 +245,13 @@ const props = defineProps<{
|
|||||||
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
|
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-modal__vote-footer home-vote-picker__footer">
|
<HomeWizardFooter
|
||||||
<div><span>{{ props.voteCount }}</span> / {{ props.totalCats }} Kategorien gewählt</div>
|
:progress-text="props.readonlyMode ? `${props.totalCats} Kategorien` : `${props.voteCount} / ${props.totalCats} Kategorien gewählt`"
|
||||||
<button
|
:helper-text="footerHelperText"
|
||||||
@click="props.submitVote"
|
:actions="footerActions"
|
||||||
: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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
<template>
|
<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);">
|
<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>
|
<HomeStarField :count="17" variant="light" />
|
||||||
<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>
|
|
||||||
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
<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 style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">★ Die Stars</div>
|
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">★ 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>
|
</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 →</button>
|
<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 →</button>
|
||||||
</div>
|
</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 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 style="display:flex;gap:22px;width:max-content;">
|
<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:360px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
|
<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="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="display:flex;align-items:center;gap:18px;margin-top:28px;margin-bottom:20px;min-height:120px;">
|
||||||
<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="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="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-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>
|
<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>
|
</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>
|
<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>
|
||||||
<div style="padding:0 24px 24px;background:#1b0d2d;">
|
<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;">Archivierter Gewinner</div>
|
<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>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,12 +55,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import HomeStarField from './HomeStarField.vue'
|
||||||
|
|
||||||
type WinnerCard = {
|
type WinnerCard = {
|
||||||
category: string
|
category: string
|
||||||
name: string
|
name: string
|
||||||
handle: string
|
handle: string
|
||||||
platform: string
|
platform: string
|
||||||
url: string
|
url: string
|
||||||
|
clipUrl: string | null
|
||||||
|
clipTitle: string
|
||||||
|
clipPlatform: string
|
||||||
|
clipEmbedUrl: string | null
|
||||||
|
clipEmbedTitle: string
|
||||||
|
hasClip: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
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 {
|
export interface HomeNominationSubmitContext {
|
||||||
categoryIndex: number
|
entries: Array<{
|
||||||
streamUrl: string
|
categoryIndex: number
|
||||||
|
streamUrls: string[]
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeDisplayCategory {
|
export interface HomeDisplayCategory {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface HomeCategoryListItem {
|
|||||||
export interface HomeNomineeListItem {
|
export interface HomeNomineeListItem {
|
||||||
name: string
|
name: string
|
||||||
handle: string
|
handle: string
|
||||||
|
url: string | null
|
||||||
platform: string
|
platform: string
|
||||||
initials: string
|
initials: string
|
||||||
clipUrl: string | null
|
clipUrl: string | null
|
||||||
@@ -48,6 +49,12 @@ export interface HomeArchiveWinnerItem {
|
|||||||
handle: string
|
handle: string
|
||||||
platform: string
|
platform: string
|
||||||
url: string
|
url: string
|
||||||
|
clipUrl: string | null
|
||||||
|
clipTitle: string
|
||||||
|
clipPlatform: string
|
||||||
|
clipEmbedUrl: string | null
|
||||||
|
clipEmbedTitle: string
|
||||||
|
hasClip: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeSelectedArchive {
|
export interface HomeSelectedArchive {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, type Ref } from 'vue'
|
import { computed, type Ref } from 'vue'
|
||||||
|
|
||||||
|
import { buildClipEmbed } from '../../lib/clipEmbeds'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||||
@@ -37,13 +38,7 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
|
|||||||
|
|
||||||
const selectedArchive = computed(() => ({
|
const selectedArchive = computed(() => ({
|
||||||
year: store.archive.year,
|
year: store.archive.year,
|
||||||
winners: store.archive.items.map((winner) => ({
|
winners: store.archive.items.map((winner) => toArchiveWinnerItem(winner)),
|
||||||
category: winner.category,
|
|
||||||
name: winner.winnerName,
|
|
||||||
handle: winner.winnerSlug,
|
|
||||||
platform: winner.winnerPlatform,
|
|
||||||
url: winner.winnerUrl,
|
|
||||||
})),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
|
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;"
|
: "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) {
|
function winnerPlatformKey(url: string) {
|
||||||
const normalized = url.toLowerCase()
|
const normalized = url.toLowerCase()
|
||||||
if (normalized.includes('twitch.tv')) return 'twitch'
|
if (normalized.includes('twitch.tv')) return 'twitch'
|
||||||
|
|||||||
@@ -120,10 +120,11 @@ export function useHomeModalCandidatePresentation(params: {
|
|||||||
const clipUrl = candidate.clipUrl?.trim() || null
|
const clipUrl = candidate.clipUrl?.trim() || null
|
||||||
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
|
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
|
||||||
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
|
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 {
|
return {
|
||||||
name: candidate.displayName,
|
name: candidate.displayName,
|
||||||
handle: candidate.channelSlug,
|
handle: candidate.channelSlug,
|
||||||
|
url: resolveCandidateUrl(candidate.channelUrl, candidate.channelSlug),
|
||||||
platform: candidate.platform,
|
platform: candidate.platform,
|
||||||
initials: initialsFor(candidate.displayName),
|
initials: initialsFor(candidate.displayName),
|
||||||
clipUrl,
|
clipUrl,
|
||||||
@@ -138,7 +139,7 @@ export function useHomeModalCandidatePresentation(params: {
|
|||||||
onPick: () => cat && pickNominee(cat.id, idx),
|
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;'}`,
|
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;'),
|
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) {
|
function formatDateLabel(value: string) {
|
||||||
if (!value) return 'Noch nicht terminiert'
|
if (!value) return 'Noch nicht terminiert'
|
||||||
const date = new Date(`${value}T00:00:00`)
|
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 { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
@@ -21,6 +21,8 @@ export function useHomeLandingState() {
|
|||||||
|
|
||||||
const activeCat = ref(0)
|
const activeCat = ref(0)
|
||||||
const previewPhase = ref<HomePreviewPhase>('voting')
|
const previewPhase = ref<HomePreviewPhase>('voting')
|
||||||
|
const clipSubmissionsEnabled = computed(() => store.overview.featureFlags.clipSubmissionsEnabled)
|
||||||
|
const clipSubmissionDisabledMessage = computed(() => store.overview.featureFlags.clipSubmissionDisabledMessage)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
role,
|
role,
|
||||||
@@ -155,6 +157,7 @@ export function useHomeLandingState() {
|
|||||||
voteCount,
|
voteCount,
|
||||||
totalCats,
|
totalCats,
|
||||||
canSubmitVote,
|
canSubmitVote,
|
||||||
|
savedVoteActive,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
activeCatName,
|
activeCatName,
|
||||||
pickerTitle,
|
pickerTitle,
|
||||||
@@ -230,7 +233,7 @@ export function useHomeLandingState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openClipForPhase(event?: Event) {
|
function openClipForPhase(event?: Event) {
|
||||||
if (!nominationPhase.value) {
|
if (!nominationPhase.value || !clipSubmissionsEnabled.value) {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,8 +241,8 @@ export function useHomeLandingState() {
|
|||||||
openClip(event)
|
openClip(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(previewPhase, (phase) => {
|
watch([previewPhase, clipSubmissionsEnabled], ([phase, clipsEnabled]) => {
|
||||||
if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) {
|
if ((phase !== 'voting' && modal.value === 'vote') || ((phase !== 'nomination' || !clipsEnabled) && modal.value === 'clip')) {
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -282,6 +285,7 @@ export function useHomeLandingState() {
|
|||||||
voteCount,
|
voteCount,
|
||||||
totalCats,
|
totalCats,
|
||||||
canSubmitVote,
|
canSubmitVote,
|
||||||
|
savedVoteActive,
|
||||||
activeCatName,
|
activeCatName,
|
||||||
phaseCardTitle,
|
phaseCardTitle,
|
||||||
phaseCardDescription,
|
phaseCardDescription,
|
||||||
@@ -318,6 +322,8 @@ export function useHomeLandingState() {
|
|||||||
catOptions,
|
catOptions,
|
||||||
canSubmitClip,
|
canSubmitClip,
|
||||||
clipSubmitStyle,
|
clipSubmitStyle,
|
||||||
|
clipSubmissionsEnabled,
|
||||||
|
clipSubmissionDisabledMessage,
|
||||||
archiveYears,
|
archiveYears,
|
||||||
selectedArchive,
|
selectedArchive,
|
||||||
winnerShowcase,
|
winnerShowcase,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Router } from 'vue-router'
|
|||||||
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
import type { HomeNominationSubmitContext } from './homeLandingTypes'
|
|
||||||
|
|
||||||
type AuthStore = ReturnType<typeof useAuthStore>
|
type AuthStore = ReturnType<typeof useAuthStore>
|
||||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||||
@@ -38,7 +37,6 @@ interface UseHomeLandingViewEffectsParams {
|
|||||||
preparationPhase: Readonly<Ref<boolean>>
|
preparationPhase: Readonly<Ref<boolean>>
|
||||||
completedPhase: Readonly<Ref<boolean>>
|
completedPhase: Readonly<Ref<boolean>>
|
||||||
initializeHomeInteractions: () => Promise<void>
|
initializeHomeInteractions: () => Promise<void>
|
||||||
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
|
|
||||||
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
|
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +57,12 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
preparationPhase,
|
preparationPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
initializeHomeInteractions,
|
initializeHomeInteractions,
|
||||||
submitNomination,
|
|
||||||
submitClip,
|
submitClip,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const rootEl = ref<HTMLElement | null>(null)
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
const landingLoaderVisible = ref(true)
|
const landingLoaderVisible = ref(true)
|
||||||
const nominationCatEl = ref<HTMLSelectElement | null>(null)
|
const stickyCountdownVisible = ref(false)
|
||||||
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
|
|
||||||
const clipUrlEl = ref<HTMLInputElement | null>(null)
|
const clipUrlEl = ref<HTMLInputElement | null>(null)
|
||||||
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
|
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
|
||||||
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
|
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
@@ -81,6 +77,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
bhEl: ref<HTMLElement | null>(null),
|
bhEl: ref<HTMLElement | null>(null),
|
||||||
bmEl: ref<HTMLElement | null>(null),
|
bmEl: ref<HTMLElement | null>(null),
|
||||||
bsEl: 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 timer: ReturnType<typeof setInterval> | null = null
|
||||||
let landingLoaderTimer: ReturnType<typeof setTimeout> | 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() {
|
function assignDomRefs() {
|
||||||
const root = rootEl.value
|
const root = rootEl.value
|
||||||
if (!root) return
|
if (!root) return
|
||||||
@@ -117,8 +111,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]')
|
countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]')
|
||||||
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
|
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
|
||||||
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
|
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
|
||||||
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
|
countdownRefs.stickyLabelEl.value = root.querySelector('[data-dc-ref="stickyCountdownLabelRef"]')
|
||||||
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
|
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"]')
|
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
|
||||||
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
|
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
|
||||||
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
|
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
|
||||||
@@ -130,18 +127,30 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
root.querySelectorAll<HTMLElement>('[style-hover]').forEach((element) => {
|
root.querySelectorAll<HTMLElement>('[style-hover]').forEach((element) => {
|
||||||
if (element.dataset.hoverBound === '1') return
|
if (element.dataset.hoverBound === '1') return
|
||||||
element.dataset.hoverBound = '1'
|
element.dataset.hoverBound = '1'
|
||||||
const base = element.getAttribute('style') ?? ''
|
|
||||||
const hover = element.getAttribute('style-hover') ?? ''
|
const hover = element.getAttribute('style-hover') ?? ''
|
||||||
element.addEventListener('mouseenter', () => { element.setAttribute('style', `${base}${hover}`) })
|
element.addEventListener('mouseenter', () => {
|
||||||
element.addEventListener('mouseleave', () => { element.setAttribute('style', base) })
|
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) => {
|
root.querySelectorAll<HTMLElement>('[style-focus]').forEach((element) => {
|
||||||
if (element.dataset.focusBound === '1') return
|
if (element.dataset.focusBound === '1') return
|
||||||
element.dataset.focusBound = '1'
|
element.dataset.focusBound = '1'
|
||||||
const base = element.getAttribute('style') ?? ''
|
|
||||||
const focus = element.getAttribute('style-focus') ?? ''
|
const focus = element.getAttribute('style-focus') ?? ''
|
||||||
element.addEventListener('focus', () => { element.setAttribute('style', `${base}${focus}`) })
|
element.addEventListener('focus', () => {
|
||||||
element.addEventListener('blur', () => { element.setAttribute('style', base) })
|
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.bhEl.value, pad(showCountdown.h))
|
||||||
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
|
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
|
||||||
setText(countdownRefs.bsEl.value, pad(showCountdown.s))
|
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 {
|
function resolveSelectedPhaseKey(): HomeTimelineKey {
|
||||||
@@ -183,6 +212,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
|
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
|
||||||
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
|
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
|
||||||
nextTick(setupDom)
|
nextTick(setupDom)
|
||||||
|
updateStickyCountdownVisibility()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([submitted, streamLive, archiveYear], () => {
|
watch([submitted, streamLive, archiveYear], () => {
|
||||||
@@ -204,6 +234,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
await initializeHomeInteractions()
|
await initializeHomeInteractions()
|
||||||
setupDom()
|
setupDom()
|
||||||
tick()
|
tick()
|
||||||
|
updateStickyCountdownVisibility()
|
||||||
|
window.addEventListener('scroll', updateStickyCountdownVisibility, { passive: true })
|
||||||
|
window.addEventListener('resize', updateStickyCountdownVisibility)
|
||||||
timer = setInterval(tick, 1000)
|
timer = setInterval(tick, 1000)
|
||||||
landingLoaderTimer = setTimeout(() => {
|
landingLoaderTimer = setTimeout(() => {
|
||||||
landingLoaderVisible.value = false
|
landingLoaderVisible.value = false
|
||||||
@@ -212,6 +245,8 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
|
window.removeEventListener('scroll', updateStickyCountdownVisibility)
|
||||||
|
window.removeEventListener('resize', updateStickyCountdownVisibility)
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
|
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
|
||||||
})
|
})
|
||||||
@@ -219,7 +254,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
return {
|
return {
|
||||||
setRootEl,
|
setRootEl,
|
||||||
landingLoaderVisible,
|
landingLoaderVisible,
|
||||||
handleNominationSubmit,
|
stickyCountdownVisible,
|
||||||
handleClipSubmit,
|
handleClipSubmit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ export function useHomeParticipationActions(params: {
|
|||||||
openModal: (kind: HomeInteractionModalKind) => void
|
openModal: (kind: HomeInteractionModalKind) => void
|
||||||
closeAccountModal: () => void
|
closeAccountModal: () => void
|
||||||
votes: Ref<Record<string, number>>
|
votes: Ref<Record<string, number>>
|
||||||
|
savedVoteCount: Ref<number>
|
||||||
submitted: Ref<boolean>
|
submitted: Ref<boolean>
|
||||||
submitting: Ref<boolean>
|
submitting: Ref<boolean>
|
||||||
formError: Ref<string>
|
formError: Ref<string>
|
||||||
successKind: Ref<null | HomeSuccessKind>
|
successKind: Ref<null | HomeSuccessKind>
|
||||||
clipCatIdx: Ref<number>
|
clipCatIdx: Ref<number>
|
||||||
clipDsgvo: Ref<boolean>
|
clipDsgvo: Ref<boolean>
|
||||||
|
clipSubmissionsEnabled: ComputedRef<boolean>
|
||||||
|
clipSubmissionDisabledMessage: ComputedRef<string>
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
store,
|
store,
|
||||||
@@ -43,12 +46,15 @@ export function useHomeParticipationActions(params: {
|
|||||||
openModal,
|
openModal,
|
||||||
closeAccountModal,
|
closeAccountModal,
|
||||||
votes,
|
votes,
|
||||||
|
savedVoteCount,
|
||||||
submitted,
|
submitted,
|
||||||
submitting,
|
submitting,
|
||||||
formError,
|
formError,
|
||||||
successKind,
|
successKind,
|
||||||
clipCatIdx,
|
clipCatIdx,
|
||||||
clipDsgvo,
|
clipDsgvo,
|
||||||
|
clipSubmissionsEnabled,
|
||||||
|
clipSubmissionDisabledMessage,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -64,6 +70,7 @@ export function useHomeParticipationActions(params: {
|
|||||||
routerPush,
|
routerPush,
|
||||||
displayCategories,
|
displayCategories,
|
||||||
votes,
|
votes,
|
||||||
|
savedVoteCount,
|
||||||
formError,
|
formError,
|
||||||
deleteConfirm,
|
deleteConfirm,
|
||||||
accountActionError,
|
accountActionError,
|
||||||
@@ -85,6 +92,7 @@ export function useHomeParticipationActions(params: {
|
|||||||
activeCat,
|
activeCat,
|
||||||
previewPhase,
|
previewPhase,
|
||||||
votes,
|
votes,
|
||||||
|
savedVoteCount,
|
||||||
submitted,
|
submitted,
|
||||||
submitting,
|
submitting,
|
||||||
formError,
|
formError,
|
||||||
@@ -94,6 +102,8 @@ export function useHomeParticipationActions(params: {
|
|||||||
ensureViewerSession,
|
ensureViewerSession,
|
||||||
loadMyParticipation,
|
loadMyParticipation,
|
||||||
fallbackCreatorName: twitchUser,
|
fallbackCreatorName: twitchUser,
|
||||||
|
clipSubmissionsEnabled,
|
||||||
|
clipSubmissionDisabledMessage,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function initializeHomeInteractions() {
|
async function initializeHomeInteractions() {
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ export function useHomeParticipationPresentation(params: {
|
|||||||
modal: Ref<null | HomeInteractionModalKind>
|
modal: Ref<null | HomeInteractionModalKind>
|
||||||
previewPhase: Ref<HomePreviewPhase>
|
previewPhase: Ref<HomePreviewPhase>
|
||||||
votes: Ref<Record<string, number>>
|
votes: Ref<Record<string, number>>
|
||||||
|
savedVoteCount: Ref<number>
|
||||||
submitted: Ref<boolean>
|
submitted: Ref<boolean>
|
||||||
successKind: Ref<null | HomeSuccessKind>
|
successKind: Ref<null | HomeSuccessKind>
|
||||||
clipCatIdx: Ref<number>
|
clipCatIdx: Ref<number>
|
||||||
clipDsgvo: Ref<boolean>
|
clipDsgvo: Ref<boolean>
|
||||||
|
clipSubmissionsEnabled: ComputedRef<boolean>
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
store,
|
store,
|
||||||
@@ -22,25 +24,28 @@ export function useHomeParticipationPresentation(params: {
|
|||||||
modal,
|
modal,
|
||||||
previewPhase,
|
previewPhase,
|
||||||
votes,
|
votes,
|
||||||
|
savedVoteCount,
|
||||||
submitted,
|
submitted,
|
||||||
successKind,
|
successKind,
|
||||||
clipCatIdx,
|
clipCatIdx,
|
||||||
clipDsgvo,
|
clipDsgvo,
|
||||||
|
clipSubmissionsEnabled,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const notSubmitted = computed(() => !submitted.value)
|
const notSubmitted = computed(() => !submitted.value)
|
||||||
const voteCount = computed(() => Object.keys(votes.value).length)
|
const voteCount = computed(() => Object.keys(votes.value).length)
|
||||||
const totalCats = computed(() => displayCategories.value.length)
|
const totalCats = computed(() => displayCategories.value.length)
|
||||||
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
|
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 activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
|
||||||
const activeCatName = computed(() => activeCategory.value?.name ?? '')
|
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 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 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 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 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 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;")
|
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 {
|
return {
|
||||||
@@ -48,6 +53,7 @@ export function useHomeParticipationPresentation(params: {
|
|||||||
voteCount,
|
voteCount,
|
||||||
totalCats,
|
totalCats,
|
||||||
canSubmitVote,
|
canSubmitVote,
|
||||||
|
savedVoteActive,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
activeCatName,
|
activeCatName,
|
||||||
pickerTitle,
|
pickerTitle,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function useHomeParticipationSessionActions(params: {
|
|||||||
routerPush: (path: string) => Promise<unknown>
|
routerPush: (path: string) => Promise<unknown>
|
||||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||||
votes: Ref<Record<string, number>>
|
votes: Ref<Record<string, number>>
|
||||||
|
savedVoteCount: Ref<number>
|
||||||
formError: Ref<string>
|
formError: Ref<string>
|
||||||
deleteConfirm: Ref<boolean>
|
deleteConfirm: Ref<boolean>
|
||||||
accountActionError: Ref<string>
|
accountActionError: Ref<string>
|
||||||
@@ -24,6 +25,7 @@ export function useHomeParticipationSessionActions(params: {
|
|||||||
routerPush,
|
routerPush,
|
||||||
displayCategories,
|
displayCategories,
|
||||||
votes,
|
votes,
|
||||||
|
savedVoteCount,
|
||||||
formError,
|
formError,
|
||||||
deleteConfirm,
|
deleteConfirm,
|
||||||
accountActionError,
|
accountActionError,
|
||||||
@@ -60,8 +62,10 @@ export function useHomeParticipationSessionActions(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
votes.value = nextVotes
|
votes.value = nextVotes
|
||||||
|
savedVoteCount.value = Object.keys(nextVotes).length
|
||||||
} catch {
|
} catch {
|
||||||
votes.value = {}
|
votes.value = {}
|
||||||
|
savedVoteCount.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ export function useHomeParticipationSessionActions(params: {
|
|||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
clipDsgvo.value = false
|
clipDsgvo.value = false
|
||||||
votes.value = {}
|
votes.value = {}
|
||||||
|
savedVoteCount.value = 0
|
||||||
await routerPush('/login')
|
await routerPush('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@ export function useHomeParticipationSessionActions(params: {
|
|||||||
deleteConfirm.value = false
|
deleteConfirm.value = false
|
||||||
clipDsgvo.value = false
|
clipDsgvo.value = false
|
||||||
votes.value = {}
|
votes.value = {}
|
||||||
|
savedVoteCount.value = 0
|
||||||
await store.loadHomeData()
|
await store.loadHomeData()
|
||||||
await routerPush('/login')
|
await routerPush('/login')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user