Add winner archive, host image upload and live demo data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s

Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-29 21:47:52 +02:00
parent f323e5a82c
commit 6b3b0360e7
92 changed files with 7412 additions and 583 deletions
+44
View File
@@ -92,6 +92,50 @@ public static class SeasonMappings
};
}
public static (string Platform, string Slug) InferProfileMetadataFromUrl(string? value, string fallbackName = "")
{
var trimmed = value?.Trim() ?? string.Empty;
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
{
return ("Profil", fallbackName.Trim());
}
var host = uri.Host.Trim().ToLowerInvariant();
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var platform = host switch
{
var item when item.Contains("twitch.tv", StringComparison.Ordinal) => "Twitch",
var item when item.Contains("youtube.com", StringComparison.Ordinal) || item.Contains("youtu.be", StringComparison.Ordinal) => "YouTube",
var item when item.Contains("x.com", StringComparison.Ordinal) || item.Contains("twitter.com", StringComparison.Ordinal) => "X",
var item when item.Contains("instagram.com", StringComparison.Ordinal) => "Instagram",
var item when item.Contains("discord.gg", StringComparison.Ordinal) || item.Contains("discord.com", StringComparison.Ordinal) => "Discord",
var item when item.Contains("kick.com", StringComparison.Ordinal) => "Kick",
var item when item.Contains("cake.gg", StringComparison.Ordinal) => "Cake",
_ => "Profil",
};
var slug = segments.LastOrDefault() ?? string.Empty;
if (string.Equals(platform, "YouTube", StringComparison.Ordinal) && segments.Length > 0)
{
slug = segments.FirstOrDefault(segment => segment.StartsWith('@')) ?? slug;
}
slug = Uri.UnescapeDataString(slug).Trim().Trim('/');
if (slug.StartsWith('@') && !string.Equals(platform, "YouTube", StringComparison.Ordinal))
{
slug = slug[1..];
}
if (string.IsNullOrWhiteSpace(slug))
{
slug = fallbackName.Trim();
}
return (platform, slug);
}
public static string NormalizeSeasonStreamUrl(string? value)
{
var trimmed = value?.Trim() ?? string.Empty;
@@ -0,0 +1,18 @@
namespace Backend.Contracts;
public sealed record AdminArchivedWinnerItemDto(
int Id,
int Year,
string Category,
string Subcategory,
string WinnerName,
string WinnerUrl,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record UpsertArchivedWinnerRequest(
int Year,
string Category,
string Subcategory,
string WinnerName,
string WinnerUrl);
@@ -37,6 +37,8 @@ public sealed record AdminCandidateItemDto(
string DisplayName,
string ChannelSlug,
string Platform,
int? AvgViewers,
int Votes,
int NominationTally,
string AcceptanceStatus,
string? AcceptanceNote,
@@ -3,6 +3,8 @@ namespace Backend.Contracts;
public sealed record AdminSiteSettingsResponse(
string HostDisplayName,
string HostTagline,
string HostArtistName,
string HostImageUrl,
string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
@@ -41,6 +43,7 @@ public sealed record AdminSiteSettingsResponse(
public sealed record UpdateSiteSettingsRequest(
string HostDisplayName,
string HostTagline,
string HostArtistName,
string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
@@ -64,6 +64,8 @@ public sealed record PublicStreamBannerContentDto(
public sealed record PublicSiteContentDto(
string HostDisplayName,
string HostTagline,
string HostArtistName,
string HostImageUrl,
string NewsletterUrl,
string ShareXUrl,
string ShareDiscordUrl,
+13
View File
@@ -10,6 +10,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<Candidate> Candidates => Set<Candidate>();
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
public DbSet<AwardResult> Results => Set<AwardResult>();
public DbSet<ArchivedWinner> ArchivedWinners => Set<ArchivedWinner>();
public DbSet<Nomination> Nominations => Set<Nomination>();
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
@@ -40,6 +41,9 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
{
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
entity.Property(item => item.HostTagline).HasMaxLength(160);
entity.Property(item => item.HostArtistName).HasMaxLength(120);
entity.Property(item => item.HostImageData).HasColumnType("bytea");
entity.Property(item => item.HostImageContentType).HasMaxLength(80);
entity.Property(item => item.NewsletterUrl).HasMaxLength(400);
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
@@ -186,6 +190,15 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.CategoryName).HasMaxLength(120);
});
modelBuilder.Entity<ArchivedWinner>(entity =>
{
entity.HasIndex(item => new { item.Year, item.Category, item.Subcategory }).IsUnique();
entity.Property(item => item.Category).HasMaxLength(120);
entity.Property(item => item.Subcategory).HasMaxLength(120);
entity.Property(item => item.WinnerName).HasMaxLength(120);
entity.Property(item => item.WinnerUrl).HasMaxLength(500);
});
modelBuilder.Entity<UserSession>(entity =>
{
entity.HasIndex(item => item.SessionToken).IsUnique();
+13
View File
@@ -0,0 +1,13 @@
namespace Backend.Domain;
public sealed class ArchivedWinner
{
public int Id { get; set; }
public int Year { get; set; }
public string Category { get; set; } = string.Empty;
public string Subcategory { get; set; } = string.Empty;
public string WinnerName { get; set; } = string.Empty;
public string WinnerUrl { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAt { get; set; }
}
+4
View File
@@ -5,6 +5,10 @@ public sealed class SiteSettings
public int Id { get; set; }
public string HostDisplayName { get; set; } = string.Empty;
public string HostTagline { get; set; } = string.Empty;
public string HostArtistName { get; set; } = string.Empty;
public byte[]? HostImageData { get; set; }
public string? HostImageContentType { get; set; }
public DateTimeOffset? HostImageUpdatedAt { get; set; }
public string NewsletterUrl { get; set; } = string.Empty;
public string ShareXUrl { get; set; } = string.Empty;
public string ShareDiscordUrl { get; set; } = string.Empty;
+249
View File
@@ -0,0 +1,249 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminArchiveEndpoints
{
public static RouteGroupBuilder MapAdminArchiveEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/archived-winners", GetArchivedWinners)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("GetAdminArchivedWinners");
group.MapPost("/archived-winners", CreateArchivedWinner)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("CreateAdminArchivedWinner");
group.MapPut("/archived-winners/{archivedWinnerId:int}", UpdateArchivedWinner)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("UpdateAdminArchivedWinner");
group.MapDelete("/archived-winners/{archivedWinnerId:int}", DeleteArchivedWinner)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Winners))
.WithName("DeleteAdminArchivedWinner");
return group;
}
private static async Task<IResult> GetArchivedWinners(AwardsDbContext db, CancellationToken cancellationToken)
{
var items = await db.ArchivedWinners
.AsNoTracking()
.OrderByDescending(item => item.Year)
.ThenBy(item => item.Category)
.ThenBy(item => item.Subcategory)
.ThenBy(item => item.WinnerName)
.Select(item => ToDto(item))
.ToArrayAsync(cancellationToken);
return Results.Ok(items);
}
private static async Task<IResult> CreateArchivedWinner(
HttpContext context,
UpsertArchivedWinnerRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validation = ValidateRequest(request);
if (validation is not null)
{
return validation;
}
var normalized = NormalizeRequest(request);
var duplicateExists = await db.ArchivedWinners.AnyAsync(item =>
item.Year == normalized.Year
&& item.Category == normalized.Category
&& item.Subcategory == normalized.Subcategory,
context.RequestAborted);
if (duplicateExists)
{
return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." });
}
var archivedWinner = new ArchivedWinner
{
Year = normalized.Year,
Category = normalized.Category,
Subcategory = normalized.Subcategory,
WinnerName = normalized.WinnerName,
WinnerUrl = normalized.WinnerUrl,
};
db.ArchivedWinners.Add(archivedWinner);
adminAuditService.AddEntry(
session.TwitchUserId,
"archived-winner.create",
"archivedWinner",
$"{normalized.Year}:{normalized.Category}:{normalized.Subcategory}",
$"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} angelegt.",
new
{
normalized.Year,
normalized.Category,
normalized.Subcategory,
normalized.WinnerName,
normalized.WinnerUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) });
}
private static async Task<IResult> UpdateArchivedWinner(
HttpContext context,
int archivedWinnerId,
UpsertArchivedWinnerRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var validation = ValidateRequest(request);
if (validation is not null)
{
return validation;
}
var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted);
if (archivedWinner is null)
{
return Results.NotFound();
}
var normalized = NormalizeRequest(request);
var duplicateExists = await db.ArchivedWinners.AnyAsync(item =>
item.Id != archivedWinnerId
&& item.Year == normalized.Year
&& item.Category == normalized.Category
&& item.Subcategory == normalized.Subcategory,
context.RequestAborted);
if (duplicateExists)
{
return Results.BadRequest(new { message = "Für dieses Jahr, diese Kategorie und Unterkategorie existiert bereits ein Archivgewinner." });
}
archivedWinner.Year = normalized.Year;
archivedWinner.Category = normalized.Category;
archivedWinner.Subcategory = normalized.Subcategory;
archivedWinner.WinnerName = normalized.WinnerName;
archivedWinner.WinnerUrl = normalized.WinnerUrl;
archivedWinner.UpdatedAt = DateTimeOffset.UtcNow;
adminAuditService.AddEntry(
session.TwitchUserId,
"archived-winner.update",
"archivedWinner",
archivedWinner.Id.ToString(),
$"Archivgewinner {normalized.Year} · {normalized.Category} · {normalized.Subcategory} aktualisiert.",
new
{
archivedWinner.Id,
normalized.Year,
normalized.Category,
normalized.Subcategory,
normalized.WinnerName,
normalized.WinnerUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, entry = ToDto(archivedWinner) });
}
private static async Task<IResult> DeleteArchivedWinner(
HttpContext context,
int archivedWinnerId,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var archivedWinner = await db.ArchivedWinners.FirstOrDefaultAsync(item => item.Id == archivedWinnerId, context.RequestAborted);
if (archivedWinner is null)
{
return Results.NotFound();
}
db.ArchivedWinners.Remove(archivedWinner);
adminAuditService.AddEntry(
session.TwitchUserId,
"archived-winner.delete",
"archivedWinner",
archivedWinner.Id.ToString(),
$"Archivgewinner {archivedWinner.Year} · {archivedWinner.Category} · {archivedWinner.Subcategory} gelöscht.",
new
{
archivedWinner.Id,
archivedWinner.Year,
archivedWinner.Category,
archivedWinner.Subcategory,
archivedWinner.WinnerName,
archivedWinner.WinnerUrl,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, archivedWinnerId });
}
private static AdminArchivedWinnerItemDto ToDto(ArchivedWinner item) =>
new(
item.Id,
item.Year,
item.Category,
item.Subcategory,
item.WinnerName,
item.WinnerUrl,
item.CreatedAt,
item.UpdatedAt);
private static IResult? ValidateRequest(UpsertArchivedWinnerRequest request)
{
if (request.Year < 2000 || request.Year > 3000)
{
return Results.BadRequest(new { message = "Bitte ein gültiges Archivjahr angeben." });
}
if (string.IsNullOrWhiteSpace(request.Category))
{
return Results.BadRequest(new { message = "Die Kategorie darf nicht leer sein." });
}
if (string.IsNullOrWhiteSpace(request.Subcategory))
{
return Results.BadRequest(new { message = "Die Unterkategorie darf nicht leer sein." });
}
if (string.IsNullOrWhiteSpace(request.WinnerName))
{
return Results.BadRequest(new { message = "Der Gewinnername darf nicht leer sein." });
}
var winnerUrl = request.WinnerUrl?.Trim() ?? string.Empty;
if (!Uri.TryCreate(winnerUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return Results.BadRequest(new { message = "Bitte einen gültigen http- oder https-Link angeben." });
}
return null;
}
private static (int Year, string Category, string Subcategory, string WinnerName, string WinnerUrl) NormalizeRequest(UpsertArchivedWinnerRequest request) =>
(
request.Year,
SeasonMappings.NormalizePlainTextContent(request.Category),
SeasonMappings.NormalizePlainTextContent(request.Subcategory),
SeasonMappings.NormalizePlainTextContent(request.WinnerName),
request.WinnerUrl.Trim());
}
+1
View File
@@ -13,6 +13,7 @@ public static class AdminEndpoints
group.MapAdminDashboardEndpoints();
group.MapAdminSeasonManagementEndpoints();
group.MapAdminArchiveEndpoints();
group.MapAdminModerationEndpoints();
group.MapAdminExtrasEndpoints();
group.MapAdminTeamEndpoints();
@@ -28,13 +28,6 @@ public static partial class AdminModerationEndpoints
return Results.NotFound();
}
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
if (trackingFlags.Any(flag => flag.BlocksApproval)
&& !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." });
}
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
if (string.IsNullOrWhiteSpace(rawDisplayName))
@@ -193,6 +193,34 @@ public static partial class AdminSeasonManagementEndpoints
return Results.Ok(new { saved = true, candidateId = candidate.Id });
}
private static async Task<IResult> GetCandidateDeletePreview(
int candidateId,
AwardsDbContext db,
CancellationToken cancellationToken)
{
var candidate = await db.Candidates
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == candidateId, cancellationToken);
if (candidate is null)
{
return Results.NotFound();
}
var nominationCount = await db.Nominations.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
var clipCount = await db.ClipSubmissions.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
var voteCount = await db.VoteEntries.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
var resultCount = await db.Results.CountAsync(item => item.CandidateId == candidateId, cancellationToken);
return Results.Ok(new
{
candidateId,
nominationCount,
clipCount,
voteCount,
resultCount,
});
}
private static async Task<IResult> DeleteCandidate(
HttpContext context,
int candidateId,
@@ -206,6 +234,38 @@ public static partial class AdminSeasonManagementEndpoints
return Results.NotFound();
}
var linkedNominations = await db.Nominations
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedNominations.Count > 0)
{
db.Nominations.RemoveRange(linkedNominations);
}
var linkedClips = await db.ClipSubmissions
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedClips.Count > 0)
{
db.ClipSubmissions.RemoveRange(linkedClips);
}
var linkedVoteEntries = await db.VoteEntries
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedVoteEntries.Count > 0)
{
db.VoteEntries.RemoveRange(linkedVoteEntries);
}
var linkedResults = await db.Results
.Where(item => item.CandidateId == candidateId)
.ToListAsync(context.RequestAborted);
if (linkedResults.Count > 0)
{
db.Results.RemoveRange(linkedResults);
}
db.Candidates.Remove(candidate);
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -213,11 +273,29 @@ public static partial class AdminSeasonManagementEndpoints
"candidate",
candidate.Id.ToString(),
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
new { candidate.CategoryId, candidate.Platform },
new
{
candidate.CategoryId,
candidate.Platform,
deletedNominations = linkedNominations.Count,
deletedClips = linkedClips.Count,
deletedVoteEntries = linkedVoteEntries.Count,
deletedResults = linkedResults.Count,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, candidateId });
try
{
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { deleted = true, candidateId });
}
catch (DbUpdateException)
{
return Results.BadRequest(new
{
message = "Kandidat konnte nicht gelöscht werden, weil noch verknüpfte Daten blockieren.",
});
}
}
private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection<string> allowedValues)
@@ -23,11 +23,11 @@ public static partial class AdminSeasonManagementEndpoints
.FirstOrDefaultAsync(item => item.Id == 1);
var trackingRules = TrackingRulesSettings.Read(settings);
var candidates = await db.Candidates
var candidateRows = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.DisplayName)
.Select(item => new AdminCandidateItemDto(
.Select(item => new AdminCandidateRow(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
@@ -43,7 +43,7 @@ public static partial class AdminSeasonManagementEndpoints
item.ClipEmbedStatus))
.ToArrayAsync();
var candidateCounts = candidates
var candidateCounts = candidateRows
.GroupBy(item => item.CategoryId)
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
@@ -217,6 +217,11 @@ public static partial class AdminSeasonManagementEndpoints
item.CategoryId,
item.CandidateId))
.ToArrayAsync();
var candidates = BuildCandidateItems(
candidateRows,
pendingNominationRows,
reviewedNominationRows,
votingEntryRows);
var votingWorkspace = BuildVotingWorkspace(
categories,
candidates,
@@ -281,6 +286,21 @@ public static partial class AdminSeasonManagementEndpoints
int CategoryId,
int CandidateId);
private sealed record AdminCandidateRow(
int Id,
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string Platform,
int NominationTally,
string AcceptanceStatus,
string? AcceptanceNote,
string? ClipCompilationUrl,
string? ClipCompilationTitle,
string? ClipCompilationPlatform,
string ClipEmbedStatus);
private sealed record AdminNominationRow(
int Id,
int? CategoryId,
@@ -314,6 +334,79 @@ public static partial class AdminSeasonManagementEndpoints
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt);
private static AdminCandidateItemDto[] BuildCandidateItems(
AdminCandidateRow[] candidateRows,
AdminNominationRow[] pendingNominationRows,
AdminNominationRow[] reviewedNominationRows,
AdminVotingEntryRow[] votingEntryRows)
{
var allNominationRows = pendingNominationRows
.Concat(reviewedNominationRows)
.ToArray();
var voteCountByCandidate = votingEntryRows
.GroupBy(item => item.CandidateId)
.ToDictionary(group => group.Key, group => group.Count());
return candidateRows
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.Platform,
ResolveCandidateAvgViewers(item, allNominationRows),
voteCountByCandidate.GetValueOrDefault(item.Id, 0),
item.NominationTally,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
item.ClipCompilationTitle,
item.ClipCompilationPlatform,
item.ClipEmbedStatus))
.ToArray();
}
private static int? ResolveCandidateAvgViewers(AdminCandidateRow candidate, AdminNominationRow[] nominationRows)
{
var directMatch = nominationRows
.Where(item => item.CandidateId == candidate.Id && item.AvgViewers.HasValue)
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
if (directMatch.HasValue)
{
return directMatch.Value;
}
if (candidate.StreamerIdentityId.HasValue)
{
var identityMatch = nominationRows
.Where(item => item.StreamerIdentityId == candidate.StreamerIdentityId && item.AvgViewers.HasValue)
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
if (identityMatch.HasValue)
{
return identityMatch.Value;
}
}
var normalizedChannel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalizedChannel))
{
return null;
}
return nominationRows
.Where(item =>
item.AvgViewers.HasValue
&& string.Equals(item.ResolvedChannel?.Trim().TrimStart('@'), normalizedChannel, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
}
private static AdminNominationReviewItemDto ToNominationReviewItem(
AdminNominationRow item,
IEnumerable<dynamic> categoryRows,
@@ -60,6 +60,10 @@ public static partial class AdminSeasonManagementEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("UpdateAdminCandidate")
.WithOpenApi();
group.MapGet("/candidates/{candidateId:int}/delete-preview", GetCandidateDeletePreview)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("GetAdminCandidateDeletePreview")
.WithOpenApi();
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("DeleteAdminCandidate")
+139 -37
View File
@@ -14,6 +14,15 @@ public static class AdminSiteSettingsEndpoints
private const string FallbackMaintenanceTitle = "Sternenpause";
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
private const string DefaultHostImageUrl = "/assets/amaterasu2sei_2.png";
private const int MaxHostImageBytes = 8 * 1024 * 1024;
private static readonly IReadOnlyDictionary<string, string> AllowedHostImageContentTypes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/webp"] = ".webp",
};
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
@@ -25,6 +34,10 @@ public static class AdminSiteSettingsEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UpdateAdminSiteSettings")
.WithOpenApi();
group.MapPost("/site-settings/host-image", UploadHostImage)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UploadAdminHostImage")
.WithOpenApi();
group.MapGet("/operational-settings", GetOperationalSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminOperationalSettings")
@@ -68,43 +81,76 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
return Results.Ok(new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
settings.StreamBannerLiveButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
settings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
settings.StreamBannerCompletedButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]"));
return Results.Ok(MapSiteSettingsResponse(settings));
}
private static async Task<IResult> UploadHostImage(
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
if (!context.Request.HasFormContentType)
{
return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." });
}
var form = await context.Request.ReadFormAsync(context.RequestAborted);
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0)
{
return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." });
}
if (file.Length > MaxHostImageBytes)
{
return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." });
}
if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension))
{
return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." });
}
var extension = Path.GetExtension(file.FileName);
if (!string.IsNullOrWhiteSpace(extension)
&& !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase)
&& !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
&& string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)))
{
return Results.BadRequest(new { message = "Dateiendung und Bildtyp passen nicht zusammen." });
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream((int)file.Length);
await stream.CopyToAsync(memory, context.RequestAborted);
settings.HostImageData = memory.ToArray();
settings.HostImageContentType = file.ContentType;
settings.HostImageUpdatedAt = DateTimeOffset.UtcNow;
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"site-settings.host-image.upload",
"site-settings",
settings.Id.ToString(),
"Landingpage-Hostbild wurde aktualisiert.",
new
{
fileName = file.FileName,
fileSize = file.Length,
file.ContentType,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(MapSiteSettingsResponse(settings));
}
private static async Task<IResult> UpdateSiteSettings(
@@ -129,6 +175,7 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.HostArtistName = request.HostArtistName.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
@@ -189,6 +236,61 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(new { saved = true });
}
private static AdminSiteSettingsResponse MapSiteSettingsResponse(SiteSettings settings)
{
return new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.HostArtistName,
BuildHostImageUrl(settings),
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
settings.StreamBannerLiveButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
settings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
settings.StreamBannerCompletedButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]");
}
internal static string BuildHostImageUrl(SiteSettings settings)
{
if (settings.HostImageData is not { Length: > 0 })
{
return DefaultHostImageUrl;
}
var version = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
?? settings.HostImageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
return $"/api/public/host-image?v={version}";
}
private static IResult? NormalizeSiteSettingsUrls(
UpdateSiteSettingsRequest request,
out PublicSiteUrlSettings normalizedUrls,
+4
View File
@@ -14,6 +14,10 @@ public static partial class PublicEndpoints
.WithName("GetSiteStatus")
.WithOpenApi();
group.MapGet("/host-image", GetHostImage)
.WithName("GetPublicHostImage")
.WithOpenApi();
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
.WithName("GetSeasonCategories")
.WithOpenApi();
@@ -0,0 +1,35 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> GetHostImage(AwardsDbContext db)
{
var settings = await db.SiteSettings
.AsNoTracking()
.Where(item => item.Id == 1)
.Select(item => new
{
item.HostImageData,
item.HostImageContentType,
item.HostImageUpdatedAt,
})
.FirstOrDefaultAsync();
if (settings?.HostImageData is not { Length: > 0 } imageData
|| string.IsNullOrWhiteSpace(settings.HostImageContentType))
{
return Results.NotFound();
}
var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
?? imageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
return Results.File(
imageData,
settings.HostImageContentType,
entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue($"\"host-{entityTag}\""),
lastModified: settings.HostImageUpdatedAt);
}
}
@@ -89,8 +89,23 @@ public static partial class PublicEndpoints
.OrderByDescending(item => item.Year)
.ToArrayAsync();
var archivedWinnerYearRows = await db.ArchivedWinners
.AsNoTracking()
.Where(item => latestPublishedWinnerYear == null || item.Year < latestPublishedWinnerYear.Value)
.GroupBy(item => item.Year)
.Select(group => new
{
Year = group.Key,
WinnerCount = group.Count(),
})
.ToArrayAsync();
var archiveYears = archiveYearRows
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
.Concat(archivedWinnerYearRows.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount)))
.GroupBy(item => item.Year)
.Select(group => group.Last())
.OrderByDescending(item => item.Year)
.ToArray();
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
@@ -150,6 +165,8 @@ public static partial class PublicEndpoints
new PublicSiteContentDto(
siteSettings.HostDisplayName,
siteSettings.HostTagline,
siteSettings.HostArtistName,
AdminSiteSettingsEndpoints.BuildHostImageUrl(siteSettings),
siteSettings.NewsletterUrl,
siteSettings.ShareXUrl,
siteSettings.ShareDiscordUrl,
@@ -9,6 +9,42 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
{
var latestPublishedWinnerYear = await db.Results
.AsNoTracking()
.Where(result => result.Season.WinnersPublishedAt != null)
.Select(result => (int?)result.Season.Year)
.MaxAsync();
var archivedWinnerRows = await db.ArchivedWinners
.AsNoTracking()
.Where(item => item.Year == year)
.OrderBy(item => item.Category)
.ThenBy(item => item.Subcategory)
.ThenBy(item => item.WinnerName)
.ToArrayAsync();
if (archivedWinnerRows.Length > 0)
{
var archivedItems = archivedWinnerRows
.Select(item =>
{
var metadata = SeasonMappings.InferProfileMetadataFromUrl(item.WinnerUrl, item.WinnerName);
return new WinnerArchiveItemDto(
item.Subcategory,
item.Category,
item.WinnerName,
metadata.Slug,
metadata.Platform,
item.WinnerUrl,
null,
null,
null,
null);
})
.ToArray();
return Results.Ok(new WinnerArchiveResponse(year, archivedItems));
}
var season = await db.Seasons
.AsNoTracking()
.Where(item => item.Year == year)
@@ -16,7 +52,7 @@ public static partial class PublicEndpoints
.FirstOrDefaultAsync();
if (season is null)
{
return Results.NotFound();
return Results.Ok(new WinnerArchiveResponse(year, []));
}
if (season.WinnersPublishedAt is null)
@@ -24,11 +60,6 @@ public static partial class PublicEndpoints
return Results.Ok(new WinnerArchiveResponse(year, []));
}
var latestPublishedWinnerYear = await db.Results
.AsNoTracking()
.Where(result => result.Season.WinnersPublishedAt != null)
.Select(result => (int?)result.Season.Year)
.MaxAsync();
if (latestPublishedWinnerYear == season.Year)
{
return Results.Ok(new WinnerArchiveResponse(year, []));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddArchivedWinners : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ArchivedWinners",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Year = table.Column<int>(type: "integer", nullable: false),
Category = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Subcategory = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
WinnerName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
WinnerUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, 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_ArchivedWinners", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ArchivedWinners_Year_Category_Subcategory",
table: "ArchivedWinners",
columns: new[] { "Year", "Category", "Subcategory" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArchivedWinners");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class SeedDemoSponsorsAndShareLinks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE "SiteSettings"
SET
"ShareXUrl" = CASE
WHEN COALESCE(NULLIF("ShareXUrl", ''), '') IN ('', 'https://x.com/intent/tweet')
THEN 'https://x.com/intent/tweet?text=Schaut%20euch%20die%20VTuber%20Star%20Awards%20an!&url=https%3A%2F%2Faward.noveria.net'
ELSE "ShareXUrl"
END,
"ShareDiscordUrl" = CASE
WHEN COALESCE(NULLIF("ShareDiscordUrl", ''), '') IN ('', 'https://discord.gg/')
THEN 'https://discord.gg/jayuhime'
ELSE "ShareDiscordUrl"
END
WHERE "Id" = 1;
INSERT INTO "Sponsors" (
"Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt"
)
SELECT seed."Id", seed."SeasonId", seed."Name", seed."WebsiteUrl", seed."LogoUrl", seed."Description", seed."Tier", seed."SortOrder", seed."IsVisible", seed."CreatedAt", seed."UpdatedAt"
FROM (
VALUES
(9006, 1001, 'Starfall Merch Lab', 'https://example.invalid/starfall-merch', '/demo/sponsors/chibicanvas-market.svg', 'Merch-Pakete, Sticker-Bundles und kleine Giveaways fuer Community-Aktionen rund um die Show.', 'Merch Partner', 60, TRUE, TIMESTAMPTZ '2026-06-01 10:25:00+00', NULL::timestamptz),
(9007, 1001, 'Moonframe Media', 'https://example.invalid/moonframe-media', '/demo/sponsors/prismloop-audio.svg', 'Highlight-Cuts, Social-Assets und kurze Promo-Snippets fuer Voting- und Finale-Phasen.', 'Media Partner', 70, TRUE, TIMESTAMPTZ '2026-06-01 10:30:00+00', NULL::timestamptz),
(9008, 1001, 'PixelHarbor Tools', 'https://example.invalid/pixelharbor-tools', '/demo/sponsors/cloudbeacon-hosting.svg', 'Kleine Creator-Tools fuer Landingpages, Formulare und Community-Orga im Eventbetrieb.', 'Tooling Partner', 80, TRUE, TIMESTAMPTZ '2026-06-01 10:35:00+00', NULL::timestamptz)
) AS seed("Id", "SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt")
WHERE EXISTS (SELECT 1 FROM "Seasons" WHERE "Id" = seed."SeasonId")
AND NOT EXISTS (
SELECT 1
FROM "Sponsors" existing
WHERE existing."SeasonId" = seed."SeasonId"
AND existing."Name" = seed."Name"
);
SELECT setval(pg_get_serial_sequence('"Sponsors"', 'Id'), COALESCE((SELECT MAX("Id") FROM "Sponsors"), 1));
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "Sponsors"
WHERE "Id" IN (9006, 9007, 9008)
AND "SeasonId" = 1001;
UPDATE "SiteSettings"
SET
"ShareXUrl" = 'https://x.com/intent/tweet',
"ShareDiscordUrl" = 'https://discord.gg/'
WHERE "Id" = 1
AND "ShareXUrl" = 'https://x.com/intent/tweet?text=Schaut%20euch%20die%20VTuber%20Star%20Awards%20an!&url=https%3A%2F%2Faward.noveria.net'
AND "ShareDiscordUrl" = 'https://discord.gg/jayuhime';
"""
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class SeedExampleSponsorsForCurrentSeason : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
WITH target_season AS (
SELECT "Id"
FROM "Seasons"
WHERE "IsCurrent" = TRUE
ORDER BY "Year" DESC, "Id" DESC
LIMIT 1
)
INSERT INTO "Sponsors" (
"SeasonId", "Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder", "IsVisible", "CreatedAt", "UpdatedAt"
)
SELECT
target_season."Id",
seed."Name",
seed."WebsiteUrl",
seed."LogoUrl",
seed."Description",
seed."Tier",
seed."SortOrder",
TRUE,
TIMESTAMPTZ '2026-06-29 19:22:20+00',
NULL::timestamptz
FROM target_season
CROSS JOIN (
VALUES
('HoshiForge Studio', 'https://example.invalid/hoshiforge', '/demo/sponsors/hoshiforge-studio.svg', 'Branding-, Overlay- und Debuet-Visuals fuer VTuber-Projekte und Community-Events.', 'Presenting Sponsor', 10),
('NekoPixel Energy', 'https://example.invalid/nekopixel', '/demo/sponsors/nekopixel-energy.svg', 'Community-fokussierter Drink-Partner fuer lange Showabende, Watchpartys und Creator-Collabs.', 'Gold Partner', 20),
('PrismLoop Audio', 'https://example.invalid/prismloop', '/demo/sponsors/prismloop-audio.svg', 'Audio-Tools, Intro-Packs und Stream-Sounddesign fuer Live-Shows und Highlight-Clips.', 'Gold Partner', 30),
('CloudBeacon Hosting', 'https://example.invalid/cloudbeacon', '/demo/sponsors/cloudbeacon-hosting.svg', 'Skalierbares Hosting fuer Voting, Landingpages und Event-Traffic rund um Showtage.', 'Tech Partner', 40),
('ChibiCanvas Market', 'https://example.invalid/chibicanvas', '/demo/sponsors/chibicanvas-market.svg', 'Merch-, Sticker- und Artist-Marketplace mit Fokus auf VTuber, Emotes und Fanartikel.', 'Community Partner', 50)
) AS seed("Name", "WebsiteUrl", "LogoUrl", "Description", "Tier", "SortOrder")
WHERE NOT EXISTS (
SELECT 1
FROM "Sponsors" existing
WHERE existing."SeasonId" = target_season."Id"
);
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "Sponsors"
WHERE "Name" IN (
'HoshiForge Studio',
'NekoPixel Energy',
'PrismLoop Audio',
'CloudBeacon Hosting',
'ChibiCanvas Market'
);
"""
);
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(Data.AwardsDbContext))]
[Migration("20260629194000_RemoveDemoSeasons")]
public partial class RemoveDemoSeasons : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE "RiskFlags"
SET "SeasonId" = NULL
WHERE "SeasonId" IN (
SELECT "Id"
FROM "Seasons"
WHERE "IsDemo" = TRUE
);
DELETE FROM "Seasons"
WHERE "IsDemo" = TRUE;
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
@@ -0,0 +1,38 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629203000_AddHostImageUpload")]
/// <inheritdoc />
public partial class AddHostImageUpload : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageData" bytea;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageContentType" character varying(80);
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageUpdatedAt" timestamp with time zone;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageUpdatedAt";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageContentType";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageData";
"""
);
}
}
}
@@ -0,0 +1,34 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629204500_AddHostArtistName")]
/// <inheritdoc />
public partial class AddHostArtistName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostArtistName" character varying(120) NOT NULL DEFAULT '';
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostArtistName";
"""
);
}
}
}
@@ -0,0 +1,38 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629210000_EnsureRuntimeSchemaParity")]
public partial class EnsureRuntimeSchemaParity : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageData" bytea;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageContentType" character varying(80);
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostImageUpdatedAt" timestamp with time zone;
ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "HostArtistName" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "Nominations" ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300);
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE "Nominations" DROP COLUMN IF EXISTS "StreamUrl";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostArtistName";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageUpdatedAt";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageContentType";
ALTER TABLE "SiteSettings" DROP COLUMN IF EXISTS "HostImageData";
"""
);
}
}
@@ -0,0 +1,178 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(AwardsDbContext))]
[Migration("20260629213000_EnsureClipSubmissionsTable")]
public partial class EnsureClipSubmissionsTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"CategoryId" integer,
"CandidateId" integer,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"ClipUrl" character varying(500) NOT NULL,
"Title" character varying(200) NOT NULL,
"Creator" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
"Status" character varying(20) NOT NULL,
"ReviewNote" character varying(500),
"ReviewedByTwitchId" character varying(120),
"CreatedFromIp" character varying(80) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone,
CONSTRAINT "PK_ClipSubmissions" PRIMARY KEY ("Id")
);
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "SeasonId" integer NOT NULL DEFAULT 0;
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CategoryId" integer;
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CandidateId" integer;
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "SubmittedByTwitchId" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ClipUrl" character varying(500) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Title" character varying(200) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Creator" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Platform" character varying(40) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500);
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120);
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "CreatedAt" timestamp with time zone NOT NULL DEFAULT NOW();
ALTER TABLE "ClipSubmissions" ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone;
DO $vtsa_clip_constraints$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ClipSubmissions_Seasons_SeasonId'
) THEN
ALTER TABLE "ClipSubmissions"
ADD CONSTRAINT "FK_ClipSubmissions_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons"("Id") ON DELETE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId'
) THEN
ALTER TABLE "ClipSubmissions"
ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId"
FOREIGN KEY ("CandidateId") REFERENCES "Candidates"("Id") ON DELETE SET NULL;
END IF;
END;
$vtsa_clip_constraints$;
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status");
DO $vtsa_clip_seed$
DECLARE
target_season_id integer;
BEGIN
SELECT "Id"
INTO target_season_id
FROM "Seasons"
WHERE "IsCurrent" = TRUE
ORDER BY "Year" DESC
LIMIT 1;
IF target_season_id IS NULL THEN
RETURN;
END IF;
IF EXISTS (
SELECT 1
FROM "ClipSubmissions"
WHERE "SeasonId" = target_season_id
) THEN
RETURN;
END IF;
INSERT INTO "ClipSubmissions" (
"SeasonId",
"CategoryId",
"CandidateId",
"SubmittedByTwitchId",
"ClipUrl",
"Title",
"Creator",
"Platform",
"Status",
"ReviewNote",
"ReviewedByTwitchId",
"CreatedFromIp",
"CreatedAt",
"ReviewedAt"
)
SELECT
target_season_id,
candidates."CategoryId",
candidates."Id",
seed_rows.submitted_by,
seed_rows.clip_url,
seed_rows.title,
seed_rows.creator,
seed_rows.platform,
seed_rows.status,
seed_rows.review_note,
seed_rows.reviewed_by,
'127.0.0.1',
seed_rows.created_at,
seed_rows.reviewed_at
FROM (
VALUES
('vtsa_demo_current_astra_shining_star_aster', 'viewer_2001', 'https://clips.twitch.tv/demo-nova-finale', 'Astra turns the boss fight', 'viewer_2001', 'Twitch', 'approved', 'Kontext passt.', 'clip_reviewer', TIMESTAMPTZ '2026-06-20 18:12:00+00', TIMESTAMPTZ '2026-06-21 09:00:00+00'),
('vtsa_demo_current_melo_rising_star_aster', 'viewer_2002', 'https://youtu.be/demo-vera-stage', 'Melo sings the finale bridge', 'viewer_2002', 'YouTube', 'approved', NULL, 'clip_reviewer', TIMESTAMPTZ '2026-06-20 20:25:00+00', TIMESTAMPTZ '2026-06-21 09:08:00+00'),
('vtsa_demo_current_lumi_shining_star_aster', 'viewer_2003', 'https://clips.twitch.tv/demo-ember-moment', 'Lumi opens the community event', 'viewer_2003', 'Twitch', 'pending', 'Timing pruefen.', NULL, TIMESTAMPTZ '2026-06-21 11:40:00+00', NULL),
('vtsa_demo_current_velvet_shining_star_aster', 'viewer_2004', 'https://youtu.be/demo-chroma-design', 'Velvet explains the overlay rebuild', 'viewer_2004', 'YouTube', 'approved', NULL, 'clip_reviewer', TIMESTAMPTZ '2026-06-21 15:10:00+00', TIMESTAMPTZ '2026-06-22 08:45:00+00'),
('vtsa_demo_current_lumi_rising_star_aster', 'viewer_2005', 'https://clips.twitch.tv/demo-sora-community', 'Lumi lets chat design the scene', 'viewer_2005', 'Twitch', 'pending', NULL, NULL, TIMESTAMPTZ '2026-06-22 19:55:00+00', NULL)
) AS seed_rows(
channel_slug,
submitted_by,
clip_url,
title,
creator,
platform,
status,
review_note,
reviewed_by,
created_at,
reviewed_at
)
INNER JOIN "Candidates" candidates
ON candidates."SeasonId" = target_season_id
AND lower(candidates."ChannelSlug") = lower(seed_rows.channel_slug);
PERFORM setval(
pg_get_serial_sequence('"ClipSubmissions"', 'Id'),
COALESCE((SELECT MAX("Id") FROM "ClipSubmissions"), 1)
);
END;
$vtsa_clip_seed$;
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "ClipSubmissions"
WHERE "SubmittedByTwitchId" IN ('viewer_2001', 'viewer_2002', 'viewer_2003', 'viewer_2004', 'viewer_2005');
"""
);
}
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations;
[DbContext(typeof(Data.AwardsDbContext))]
[Migration("20260629220000_SeedDemoArchivedWinners2025")]
public partial class SeedDemoArchivedWinners2025 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $vtsa_archive_2025$
BEGIN
IF EXISTS (
SELECT 1 FROM "Seasons"
WHERE "Year" = 2025 AND "IsDemo" = FALSE
) THEN
RAISE NOTICE 'Echte 2025-Season vorhanden; Demo-Archivgewinner werden nicht eingespielt.';
RETURN;
END IF;
INSERT INTO "ArchivedWinners" (
"Year", "Category", "Subcategory", "WinnerName", "WinnerUrl", "CreatedAt"
)
VALUES
(2025, 'Gaming', 'Hidden Star', 'Archiv Aster', 'https://twitch.tv/archiv_aster', TIMESTAMPTZ '2025-06-22 10:05:00+00'),
(2025, 'Gaming', 'Rising Star', 'Archiv Beryl', 'https://twitch.tv/archiv_beryl', TIMESTAMPTZ '2025-06-22 10:06:00+00'),
(2025, 'Gaming', 'Shining Star', 'Archiv Coda', 'https://clips.twitch.tv/demo-archiv-coda', TIMESTAMPTZ '2025-06-22 10:07:00+00'),
(2025, 'Music', 'Hidden Star', 'Archiv Drift', 'https://twitch.tv/archiv_drift', TIMESTAMPTZ '2025-06-22 10:08:00+00'),
(2025, 'Music', 'Rising Star', 'Archiv Elara', 'https://youtu.be/demo-archiv-elara', TIMESTAMPTZ '2025-06-22 10:09:00+00'),
(2025, 'Music', 'Shining Star', 'Archiv Finch', 'https://clips.twitch.tv/demo-archiv-finch', TIMESTAMPTZ '2025-06-22 10:10:00+00'),
(2025, 'Community', 'Hidden Star', 'Archiv Lyra', 'https://twitch.tv/archiv_lyra', TIMESTAMPTZ '2025-06-22 10:11:00+00'),
(2025, 'Community', 'Rising Star', 'Archiv Muse', 'https://youtu.be/demo-archiv-muse', TIMESTAMPTZ '2025-06-22 10:12:00+00'),
(2025, 'Community', 'Shining Star', 'Archiv Nia', 'https://clips.twitch.tv/demo-archiv-nia', TIMESTAMPTZ '2025-06-22 10:13:00+00')
ON CONFLICT ("Year", "Category", "Subcategory") DO NOTHING;
PERFORM setval(pg_get_serial_sequence('"ArchivedWinners"', 'Id'), COALESCE((SELECT MAX("Id") FROM "ArchivedWinners"), 1));
END;
$vtsa_archive_2025$;
"""
);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DELETE FROM "ArchivedWinners"
WHERE "Year" = 2025
AND "Category" IN ('Gaming', 'Music', 'Community')
AND "WinnerName" LIKE 'Archiv %';
"""
);
}
}
@@ -77,6 +77,51 @@ namespace Backend.Migrations
b.ToTable("AdminAuditEntries");
});
modelBuilder.Entity("Backend.Domain.ArchivedWinner", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Subcategory")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("WinnerName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("WinnerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Year", "Category", "Subcategory")
.IsUnique();
b.ToTable("ArchivedWinners");
});
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{
b.Property<int>("Id")
@@ -779,11 +824,26 @@ namespace Backend.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("HostArtistName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("HostDisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("HostImageContentType")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<byte[]>("HostImageData")
.HasColumnType("bytea");
b.Property<DateTimeOffset?>("HostImageUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("HostTagline")
.IsRequired()
.HasMaxLength(160)
+5 -3
View File
@@ -124,7 +124,7 @@ public static class TrackingRulesSettings
new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false),
new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false),
new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false),
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false),
new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, false, false),
new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false),
new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false),
new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false),
@@ -206,7 +206,9 @@ public static class TrackingRulesSettings
try
{
return JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [];
return (JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [])
.Select(flag => flag with { BlocksApproval = false })
.ToArray();
}
catch (JsonException)
{
@@ -333,7 +335,7 @@ public static class TrackingRulesSettings
Severity = severity,
AutoTriggerEnabled = stored.AutoTriggerEnabled,
RequiresManualReview = stored.RequiresManualReview,
BlocksApproval = stored.BlocksApproval,
BlocksApproval = false,
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
};
}
+1 -1
View File
@@ -135,7 +135,7 @@ and smaller Vue files split by feature.
| Data | Owner | Notes |
| --- | --- | --- |
| Seasons, categories, candidates, winners | Backend | Public and admin views read from canonical backend state. |
| Seasons, categories, candidates, winners, archived winners | Backend | Public and admin views read from canonical backend state; historical archive winners are managed separately from current season winner publication. |
| Nominations and clip submissions | Backend | Public writes are validated and reviewed through admin workflows. |
| Site settings, landing content, footer links, showacts, sponsors | Backend | Content hub/admin settings own public presentation data. |
| Team members, roles, permissions, sessions | Backend | UI may hide controls, but API authorization is authoritative. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

@@ -1,17 +1,49 @@
<template>
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Das lässt sich nicht rückgängig machen.
<div class="space-y-4">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Alle verknüpften Daten werden beim Bestätigen mitgelöscht. Das lässt sich nicht rückgängig machen.
</p>
</div>
<div v-if="loading" class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm text-slate-600">
Löschvorschau wird geladen...
</div>
<div v-else-if="preview" class="rounded-2xl border border-rose-100 bg-rose-50/60 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-rose-600">Mitbetroffene Daten</p>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Nominierungen</p>
<strong class="mt-1 block text-lg text-rose-700">{{ preview.nominationCount }}</strong>
</div>
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Clips</p>
<strong class="mt-1 block text-lg text-rose-700">{{ preview.clipCount }}</strong>
</div>
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Votes</p>
<strong class="mt-1 block text-lg text-rose-700">{{ preview.voteCount }}</strong>
</div>
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Ergebnisse</p>
<strong class="mt-1 block text-lg text-rose-700">{{ preview.resultCount }}</strong>
</div>
</div>
</div>
<p v-else class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3 text-sm text-amber-800">
Die Löschvorschau konnte nicht geladen werden. Bitte prüfe die Fehlermeldung, bevor du fortfährst.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="$emit('confirm')">
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting || loading || !preview" @click="$emit('confirm')">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
@@ -21,12 +53,14 @@
<script setup lang="ts">
import { TriangleAlert } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import type { AdminCandidateDeletePreview, AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
defineProps<{
candidate: AdminCandidateItem | null
preview: AdminCandidateDeletePreview | null
loading: boolean
deleting: boolean
}>()
@@ -34,24 +34,32 @@
<section v-if="identitySummary || ruleNotices.length" 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">Regel-Kontext</p>
<p class="mt-1 text-sm text-slate-500">Diese Werte helfen bei mehrfachen Nominierungen und Gewinnergrenzen.</p>
<p class="mt-1 text-sm text-slate-500">Diese Werte helfen bei mehrfachen Nominierungen und der Voting-Vorbereitung.</p>
</div>
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-4">
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-3">
<div class="group relative rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Kandidaturen</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.appearances }}</strong>
<div class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-xl bg-slate-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
Wie oft diese Person (erkannt über Handle + Plattform) saisonweit als Kandidat eingetragen ist abgelehnte Einträge zählen nicht mit.
<span class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-900" />
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<div class="group relative rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Angenommen</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.acceptedAppearances }}</strong>
<div class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-xl bg-slate-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
Wie viele dieser Kandidaturen explizit auf Angenommen" gesetzt wurden. 0 bedeutet: noch kein Eintrag dieser Person wurde bestätigt.
<span class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-900" />
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Gewinner</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.winnerPlacements }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Viewer-Nominierungen</p>
<div class="group relative rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Nominierungen</p>
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.nominationTally }}</strong>
<div class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-xl bg-slate-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
Wie viele Viewer diese Person im Nominierungsprozess vorgeschlagen haben. Beeinflusst keine Regeln, dient als Orientierung für die Kandidaten-Auswahl.
<span class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-900" />
</div>
</div>
</div>
<div v-if="ruleNotices.length" class="mt-3 space-y-2">
@@ -23,11 +23,9 @@
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
<p class="mt-1 text-xs text-slate-500">
{{ candidate.nominationTally }} Viewer-Nominierungen
<template v-if="candidateIdentitySummaries[identityKey(candidate)]">
· {{ candidateIdentitySummaries[identityKey(candidate)].appearances }} Kandidaturen
· {{ candidateIdentitySummaries[identityKey(candidate)].winnerPlacements }} Gewinnerplätze
</template>
{{ avgViewerLabel(candidate.avgViewers) }}
· {{ candidate.nominationTally.toLocaleString('de-DE') }} {{ candidate.nominationTally === 1 ? 'Nominierung' : 'Nominierungen' }}
· {{ candidate.votes.toLocaleString('de-DE') }} {{ candidate.votes === 1 ? 'Vote' : 'Votes' }}
</p>
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat in dieser Kategorie
@@ -129,7 +127,6 @@ const props = defineProps<{
rangeEnd: number
categoryLabelMap: Record<number, string>
duplicateCandidateKeys: Map<string, number>
candidateIdentitySummaries: Record<string, { appearances: number; acceptedAppearances: number; winnerPlacements: number; nominationTally: number }>
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
}>()
@@ -146,15 +143,6 @@ function isDuplicate(candidate: AdminCandidateItem) {
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
}
function identityKey(candidate: AdminCandidateItem) {
if (typeof candidate.streamerIdentityId === 'number') {
return `identity:${candidate.streamerIdentityId}`
}
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function acceptanceLabel(value: string) {
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
}
@@ -172,4 +160,10 @@ function embedLabel(value: string) {
if (value === 'blocked') return 'Nicht nutzbar'
return 'Embed prüfen'
}
function avgViewerLabel(value: number | null) {
return value != null
? `Ø ${value.toLocaleString('de-DE')} Viewer`
: 'Tracker offen'
}
</script>
@@ -3,7 +3,7 @@
:open="open"
size="lg"
:title="title"
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season. Die Beschreibung erscheint auf der Landingpage in der Awards-Karte."
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewählten Season. Die Beschreibung erscheint auf der Landingpage in der Awards-Karte."
@close="$emit('close')"
>
<div class="grid gap-4 md:grid-cols-2">
@@ -47,7 +47,7 @@
<textarea
:value="form.description"
class="min-h-28 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="Kurzer Beschreibungstext fuer die Awards-Sektion auf der Landingpage"
placeholder="Kurzer Beschreibungstext für die Awards-Sektion auf der Landingpage"
@input="form.description = ($event.target as HTMLTextAreaElement).value"
/>
</label>
@@ -3,7 +3,7 @@
:open="open"
size="xl"
title="Unterkategorien konfigurieren"
subtitle="Diese Viewer-Sektionen gelten fuer alle Hauptkategorien der ausgewaehlten Season."
subtitle="Diese Viewer-Sektionen gelten für alle Hauptkategorien der ausgewählten Season."
@close="$emit('close')"
>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
@@ -11,7 +11,7 @@
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-900">Globale Unterkategorien</p>
<p class="text-sm text-slate-500">Die Hauptkategorien muessen sie nicht einzeln anlegen.</p>
<p class="text-sm text-slate-500">Die Hauptkategorien müssen sie nicht einzeln anlegen.</p>
</div>
<Button class="gap-2" @click="$emit('add-subcategory')">
<PlusCircle class="h-4 w-4" />
@@ -139,7 +139,7 @@
<div v-else class="flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed border-violet-200 bg-white/60 px-4 py-8 text-center">
<Layers3 class="h-8 w-8 text-violet-300" />
<p class="mt-3 font-semibold text-slate-700">Unterkategorie auswaehlen</p>
<p class="mt-3 font-semibold text-slate-700">Unterkategorie auswählen</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Bearbeite eine bestehende Sektion oder lege eine neue Viewer-Sektion an.</p>
</div>
</aside>
@@ -17,11 +17,45 @@
</div>
</div>
<div class="grid gap-4 p-6 md:grid-cols-2">
<div class="md:col-span-2 grid gap-4 rounded-[24px] border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-fuchsia-50/60 p-4 sm:grid-cols-[minmax(0,1fr)_220px]">
<div class="flex flex-col justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Hostbild Landingpage</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Hero-Grafik austauschen</h3>
<p class="mt-2 text-sm leading-6 text-slate-500">
Wird rechts im Landingpage-Hero angezeigt und nach dem Upload dauerhaft gespeichert. Erlaubt sind PNG, JPG oder WebP bis 8 MB.
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-violet-600 px-4 py-2.5 text-sm font-bold text-white shadow-lg shadow-violet-500/20 transition hover:-translate-y-0.5 hover:bg-violet-700">
<Upload class="h-4 w-4" />
{{ hostImageUploading ? 'Lädt hoch ...' : 'Hostbild hochladen' }}
<input class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" :disabled="hostImageUploading || saving" @change="handleHostImageUpload" />
</label>
<span class="text-xs font-semibold text-slate-400">Aktuell: {{ form.hostImageUrl || 'Fallback-Bild' }}</span>
</div>
<p v-if="hostImageUploadError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ hostImageUploadError }}
</p>
</div>
<div class="relative min-h-[220px] overflow-hidden rounded-[22px] border border-white/80 bg-[#f4eefb] shadow-inner shadow-violet-100">
<img
:src="form.hostImageUrl || '/assets/amaterasu2sei_2.png'"
alt="Aktuelles Hostbild"
class="absolute inset-x-0 bottom-0 mx-auto h-[260px] w-auto object-contain drop-shadow-2xl"
/>
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-[#f4eefb] to-transparent"></div>
</div>
</div>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Name</span>
<input v-model="form.hostDisplayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Artist Name</span>
<input v-model="form.hostArtistName" type="text" placeholder="z.B. Illustrator:in des Hostbilds" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Tagline</span>
<input v-model="form.hostTagline" type="text" 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>
@@ -42,7 +76,7 @@
</template>
<script setup lang="ts">
import { Globe2, Save } from '@lucide/vue'
import { Globe2, Save, Upload } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
@@ -51,6 +85,9 @@ import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
hostImageUploading: boolean
hostImageUploadError: string
handleHostImageUpload: (event: Event) => Promise<void>
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
@@ -134,7 +134,7 @@ const showactStatusText = computed(() => {
if (settingsForm.showactApplicationsOpenNow) {
return parts.length > 0
? `Bewerbungen sind aktuell offen. ${parts.join(' · ')}.`
: 'Bewerbungen sind aktuell offen und das Modal bleibt fuer Einreichungen verfuegbar.'
: 'Bewerbungen sind aktuell offen und das Modal bleibt für Einreichungen verfügbar.'
}
if (parts.length > 0) {
@@ -470,7 +470,7 @@ onMounted(loadLandingExtras)
</div>
</div>
<div v-if="store.adminShowactApplications.length === 0" class="p-4 text-sm text-slate-500">
Noch keine Bewerbungen fuer dieses Jahr.
Noch keine Bewerbungen für 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">
@@ -17,7 +17,7 @@
v-model="form.streamBannerEyebrow"
type="text"
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"
placeholder="Das grosse Finale"
placeholder="Das große Finale"
/>
</label>
<label class="space-y-2">
@@ -87,7 +87,7 @@ async function saveBlacklist() {
<Modal
:open="open"
title="Link-Blacklist"
subtitle="Blockiert generische oder unerwuenschte Stream-Links direkt bei der oeffentlichen Nominierung."
subtitle="Blockiert generische oder unerwünschte Stream-Links direkt bei der öffentlichen Nominierung."
size="lg"
@close="emit('close')"
>
@@ -27,7 +27,6 @@ const props = defineProps<{
platforms: string[]
} | null
trackingReviewNotes?: string
selectedNominationHasBlockingFlag?: boolean
streamUrl?: string
canApproveSelected: boolean
blacklistSaving: boolean
@@ -109,7 +108,6 @@ onBeforeUnmount(() => {
:selected-related-pending-nominations="selectedRelatedPendingNominations"
:signal-summary="signalSummary"
:tracking-review-notes="trackingReviewNotes"
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
:stream-url="streamUrl"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
@@ -253,7 +253,7 @@ function toneClasses(tone: AdminSettingsTone) {
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
Der Logout reagiert auf echte Nutzeraktivität wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und läuft spätestens nach 30 Tagen absolut ab.
</div>
</div>
</section>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
@@ -25,7 +26,6 @@ const props = defineProps<{
platforms: string[]
} | null
trackingReviewNotes?: string
selectedNominationHasBlockingFlag?: boolean
streamUrl?: string
canApproveSelected: boolean
blacklistSaving: boolean
@@ -159,12 +159,23 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
class="rounded-[24px] border p-4"
:class="nomination.trackingReviewStatus === 'overridden' ? 'border-emerald-100 bg-emerald-50/70' : nomination.requiresManualReview ? 'border-amber-100 bg-amber-50/70' : 'border-violet-100 bg-white'"
>
<div class="flex items-center gap-2">
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
</div>
<RouterLink
to="/admin/tracking-rules"
class="inline-flex items-center rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:border-violet-300 hover:bg-violet-50"
>
Zur Konfiguration
</RouterLink>
</div>
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
<p class="mt-2 text-xs leading-5 text-slate-500">
Diese Tracking-Metriken blockieren keine Annahme oder Verwerfung. Sie zeigen nur, welche Review-Regel und welches Zeitfenster aktuell angewandt werden.
</p>
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
<span
@@ -172,7 +183,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
:key="flag.key"
class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-semibold text-amber-800"
>
{{ flag.label }}<span v-if="flag.blocksApproval"> · blockiert</span>
{{ flag.label }}
</span>
</div>
@@ -190,7 +201,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
</span>
</div>
<strong class="mt-1 block text-sm text-violet-900">{{ metric.value }}</strong>
<p class="mt-1 text-xs text-slate-500">{{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
<p class="mt-1 text-xs text-slate-500">Review-Regel · {{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
</div>
</div>
@@ -271,12 +282,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
</p>
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
<span v-if="selectedNominationHasBlockingFlag && nomination.trackingReviewStatus !== 'overridden'">
Mindestens ein Tracking-Flag blockiert die Freigabe. Bitte zuerst einen Override setzen.
</span>
<span v-else>
Anzeigename, Handle, Plattform und Tier sind Pflicht.
</span>
Anzeigename, Handle, Plattform und Tier sind Pflicht.
</p>
</section>
@@ -48,7 +48,6 @@ const {
selectedRelatedPendingNominations,
selectedNominationSignalSummary,
trackingReviewNotes,
selectedNominationHasBlockingFlag,
canApproveSelected,
approveNomination,
rejectNomination,
@@ -130,7 +129,6 @@ watchAdminToast(adminMessage, adminError)
:selected-related-pending-nominations="selectedRelatedPendingNominations"
:signal-summary="selectedNominationSignalSummary"
:tracking-review-notes="trackingReviewNotes"
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
@@ -87,10 +87,10 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende</span>
<span v-if="avgViewerLabel(nomination)" class="rounded-full border border-sky-100 bg-sky-50 px-2.5 py-1 text-sky-700">{{ avgViewerLabel(nomination) }}</span>
<span
v-if="nomination.trackingFlags.some((flag) => flag.blocksApproval)"
class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-rose-700"
v-if="nomination.trackingFlags.length > 0"
class="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
>
Blockierende Flags
Tracking-Hinweise
</span>
<span
v-else-if="nomination.requiresManualReview"
@@ -102,7 +102,7 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
v-if="!nomination.suggestedCategoryName"
class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-600"
>
Tier manuell waehlen
Tier manuell wählen
</span>
</div>
<p class="mt-2 text-xs font-medium text-slate-400">
@@ -36,7 +36,7 @@ const props = defineProps<{
</span>
</label>
<div class="rounded-[22px] border border-violet-100 bg-violet-50/60 p-4 text-sm leading-6 text-slate-600 sm:col-span-2">
Der Live-Link fuer das Finale kommt zentral aus dem Landingpage-Modul, damit Jahre und Banner nicht doppelt gepflegt werden.
Der Live-Link für das Finale kommt zentral aus dem Landingpage-Modul, damit Jahre und Banner nicht doppelt gepflegt werden.
</div>
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
@@ -39,7 +39,7 @@ defineProps<{
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
Der Logout reagiert auf echte Nutzeraktivität wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und läuft spätestens nach 30 Tagen absolut ab.
</div>
</div>
</section>
@@ -175,8 +175,8 @@ function removeOption(field: ShowactFormField, optionIndex: number) {
function choiceFieldHint(field: ShowactFormField) {
if (field.type === 'select') return 'Wird auf der Landingpage als Dropdown mit genau einer Auswahl gezeigt.'
if (field.type === 'radio') return 'Wird als sichtbare Liste gezeigt, aus der genau ein Eintrag gewaehlt werden kann.'
if (field.type === 'multiselect') return 'Wird als Liste mit mehreren ankreuzbaren Optionen fuer ein gemeinsames Feld gerendert.'
if (field.type === 'radio') return 'Wird als sichtbare Liste gezeigt, aus der genau ein Eintrag gewählt werden kann.'
if (field.type === 'multiselect') return 'Wird als Liste mit mehreren ankreuzbaren Optionen für ein gemeinsames Feld gerendert.'
if (field.type === 'checkbox') return 'Jede Option wird als eigene benennbare Checkbox auf der Landingpage angezeigt.'
return ''
}
@@ -487,7 +487,7 @@ function choiceFieldHint(field: ShowactFormField) {
@close="previewOpen = false"
>
<div v-if="props.form.showactFormSchema.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/60 p-6 text-center text-sm font-semibold text-slate-500">
Noch keine Felder fuer die Vorschau vorhanden.
Noch keine Felder für die Vorschau vorhanden.
</div>
<div v-else class="space-y-4">
<div
@@ -521,7 +521,7 @@ function choiceFieldHint(field: ShowactFormField) {
disabled
class="mt-3 w-full rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-500"
>
<option>{{ field.placeholder || 'Bitte waehlen' }}</option>
<option>{{ field.placeholder || 'Bitte wählen' }}</option>
<option v-for="option in field.options" :key="option">{{ option }}</option>
</select>
<div v-else-if="field.type === 'radio' || field.type === 'multiselect'" class="mt-3 space-y-2">
@@ -1,225 +0,0 @@
<script setup lang="ts">
import { Check, ChevronRight, LockKeyhole, Minus, Save } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import type { AdminTeamPermission, AdminTeamRole } from '../../types/awards'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
interface PermissionGroup {
label: string
items: AdminTeamPermission[]
}
const props = defineProps<{
open: boolean
saving: boolean
hasRoleChanges: boolean
roles: AdminTeamRole[]
permissions: AdminTeamPermission[]
permissionGroups: PermissionGroup[]
roleHasPermission: (roleKey: string, permissionKey: string) => boolean
setRolePermission: (roleKey: string, permissionKey: string, checked: boolean) => void
saveRolePermissions: () => void | Promise<void>
}>()
const emit = defineEmits<{
close: []
}>()
const selectedRoleKey = ref<string | null>(null)
const selectedRole = computed(() =>
props.roles.find((role) => role.key === selectedRoleKey.value) ?? props.roles[0] ?? null,
)
const selectedPermissionGroups = computed(() => {
if (!selectedRole.value) return []
return props.permissionGroups.map((group) => ({
...group,
grantedCount: group.items.filter((permission) => props.roleHasPermission(selectedRole.value!.key, permission.key)).length,
}))
})
watch(
() => [props.open, props.roles.map((role) => role.key).join('|')] as const,
([open]) => {
if (!open) return
if (!selectedRoleKey.value || !props.roles.some((role) => role.key === selectedRoleKey.value)) {
selectedRoleKey.value = props.roles[0]?.key ?? null
}
},
{ immediate: true },
)
function rolePermissionLocked(roleKey: string) {
return roleKey === 'owner' || roleKey === 'creator'
}
function rolePermissionCount(roleKey: string) {
return props.permissions.filter((permission) => props.roleHasPermission(roleKey, permission.key)).length
}
function permissionModeLabel(permission: AdminTeamPermission) {
return permission.readOnlySupported ? 'Lesen oder Schreiben' : 'Schreiben'
}
async function save() {
await props.saveRolePermissions()
}
</script>
<template>
<Modal
:open="open"
title="Rollenrechte"
subtitle="Rolle im Navigator wählen und Berechtigungen im Detail pflegen."
size="xl"
@close="emit('close')"
>
<div class="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]">
<aside class="space-y-3">
<div class="rounded-[24px] border border-violet-100 bg-violet-50/55 p-4">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Navigator</p>
<p class="mt-2 text-sm leading-6 text-slate-600">
Jede Rolle zeigt ihre Rechte einzeln. Das ist kompakter als die alte Matrix und passt zum aktuellen Admin-Workspace.
</p>
</div>
<button
v-for="role in roles"
:key="role.key"
type="button"
class="w-full rounded-[24px] border p-4 text-left transition"
:class="selectedRole?.key === role.key
? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#fff4e6] shadow-[0_18px_40px_rgba(139,108,219,0.14)]'
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/50'"
@click="selectedRoleKey = role.key"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500">{{ role.label }}</p>
<p class="mt-2 text-sm leading-6 text-slate-600">{{ role.description }}</p>
</div>
<span
class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl border transition"
:class="selectedRole?.key === role.key ? 'border-violet-200 bg-white text-violet-700' : 'border-violet-100 bg-violet-50 text-violet-500'"
>
<LockKeyhole v-if="rolePermissionLocked(role.key)" class="h-4 w-4" />
<ChevronRight v-else class="h-4 w-4" />
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-violet-700">
{{ rolePermissionCount(role.key) }}/{{ permissions.length }} Rechte
</span>
<span
v-if="rolePermissionLocked(role.key)"
class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-slate-500"
>
fixiert
</span>
</div>
</button>
</aside>
<section v-if="selectedRole" class="space-y-4">
<div class="rounded-[28px] border border-violet-100 bg-white/95 p-5 shadow-[0_18px_40px_rgba(124,92,255,0.08)]">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rolle</p>
<h3 class="mt-1 text-2xl font-bold text-slate-950">{{ selectedRole.label }}</h3>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">{{ selectedRole.description }}</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-bold text-violet-700">
{{ rolePermissionCount(selectedRole.key) }}/{{ permissions.length }} Rechte aktiv
</span>
<span
v-if="rolePermissionLocked(selectedRole.key)"
class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-bold text-slate-500"
>
Systemrolle
</span>
</div>
</div>
<div
v-if="rolePermissionLocked(selectedRole.key)"
class="mt-4 flex items-start gap-3 rounded-[22px] border border-slate-200 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
<p>Owner und Creator bleiben auf Vollzugriff fixiert und können hier nur eingesehen werden.</p>
</div>
</div>
<div
v-for="group in selectedPermissionGroups"
:key="group.label"
class="rounded-[28px] border border-violet-100 bg-white/92 p-5 shadow-[0_14px_34px_rgba(124,92,255,0.06)]"
>
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-violet-100 pb-4">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Bereich</p>
<h4 class="mt-1 text-lg font-bold text-slate-950">{{ group.label }}</h4>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-bold text-violet-700">
{{ group.grantedCount }}/{{ group.items.length }} aktiv
</span>
</div>
<div class="mt-4 grid gap-3">
<div
v-for="permission in group.items"
:key="permission.key"
class="grid gap-3 rounded-[24px] border border-violet-100 bg-violet-50/35 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="text-base font-bold text-slate-950">{{ permission.label }}</p>
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-violet-700">
{{ permissionModeLabel(permission) }}
</span>
<span class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-bold text-slate-500">
{{ permission.menuPath }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">{{ permission.description }}</p>
</div>
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-sm font-bold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
:class="[
roleHasPermission(selectedRole.key, permission.key)
? rolePermissionLocked(selectedRole.key)
? 'border-slate-200 bg-slate-100 text-slate-500'
: 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500'
: rolePermissionLocked(selectedRole.key)
? 'border-slate-200 bg-slate-50 text-slate-300'
: 'border-violet-200 bg-white text-violet-700 hover:bg-violet-50',
]"
:disabled="rolePermissionLocked(selectedRole.key)"
:aria-label="`${selectedRole.label}: ${permission.label}`"
:aria-pressed="roleHasPermission(selectedRole.key, permission.key)"
@click="setRolePermission(selectedRole.key, permission.key, !roleHasPermission(selectedRole.key, permission.key))"
>
<Check v-if="roleHasPermission(selectedRole.key, permission.key)" class="h-4 w-4" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
{{ roleHasPermission(selectedRole.key, permission.key) ? 'Aktiv' : 'Aus' }}
</button>
</div>
</div>
</div>
</section>
</div>
<template #footer>
<Button type="button" variant="ghost" @click="emit('close')">Schließen</Button>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="save">
<Save class="mr-2 h-4 w-4" />
{{ saving ? 'Speichert...' : 'Speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -0,0 +1,174 @@
<template>
<Modal
:open="open && leaderboardEntry !== null"
:title="leaderboardEntry?.displayName || 'Kandidatendetails'"
:subtitle="subtitle"
size="lg"
@close="$emit('close')"
>
<template v-if="leaderboardEntry">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Rang</p>
<strong class="mt-1 block text-xl text-slate-950">#{{ rankLabel }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Votes</p>
<strong class="mt-1 block text-xl text-slate-950">{{ leaderboardEntry.votes.toLocaleString('de-DE') }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Anteil</p>
<strong class="mt-1 block text-xl text-violet-800">{{ leaderboardEntry.voteSharePercent }}%</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Viewer-Nominierungen</p>
<strong class="mt-1 block text-xl text-slate-950">{{ leaderboardEntry.nominationTally }}</strong>
</div>
</div>
<section class="mt-5 rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Kanal</p>
<h4 class="mt-2 text-xl font-bold text-slate-950">{{ leaderboardEntry.displayName }}</h4>
<p class="mt-1 break-all text-sm text-slate-500">{{ leaderboardEntry.channelSlug }} · {{ leaderboardEntry.platform }}</p>
</div>
<div class="flex flex-wrap gap-2 lg:max-w-[50%] lg:justify-end">
<span v-if="leaderboardEntry.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
Gewinner
</span>
<span v-if="leaderboardEntry.isTopTie" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
Tie
</span>
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="leaderboardEntry.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ leaderboardEntry.hasClip ? 'Clip da' : 'Clip fehlt' }}
</span>
<span v-if="leaderboardEntry.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
Regelkonflikt
</span>
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="leaderboardEntry.isAccepted ? 'bg-sky-50 text-sky-700' : 'bg-slate-100 text-slate-600'">
{{ acceptanceStatusLabel(candidate?.acceptanceStatus, leaderboardEntry.isAccepted) }}
</span>
</div>
</div>
</section>
<div class="mt-5 grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Voting-Readiness</p>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Annahme</p>
<strong class="mt-1 block text-base text-slate-950">{{ acceptanceStatusLabel(candidate?.acceptanceStatus, leaderboardEntry.isAccepted) }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Clip-Embed</p>
<strong class="mt-1 block text-base text-slate-950">{{ clipEmbedStatusLabel(candidate?.clipEmbedStatus, leaderboardEntry.clipEmbedStatus) }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Ø Viewer</p>
<strong class="mt-1 block text-base text-slate-950">{{ avgViewersLabel }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidat-ID</p>
<strong class="mt-1 block text-base text-slate-950">{{ candidate?.id ?? leaderboardEntry.candidateId }}</strong>
</div>
</div>
</section>
<section class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-3">
<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">Voting-Link und aktueller Nutzungsstatus.</p>
</div>
<a
v-if="candidate?.clipCompilationUrl"
:href="candidate.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 transition hover:bg-violet-50"
>
Link öffnen
</a>
</div>
<dl class="mt-4 grid gap-3">
<div class="rounded-2xl border border-violet-100 bg-violet-50/35 px-4 py-3">
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">URL</dt>
<dd class="mt-1 break-all text-sm text-slate-900">{{ candidate?.clipCompilationUrl || 'Kein Clip-Link gepflegt.' }}</dd>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/35 px-4 py-3">
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Titel</dt>
<dd class="mt-1 text-sm text-slate-900">{{ candidate?.clipCompilationTitle || 'Kein Titel gepflegt.' }}</dd>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/35 px-4 py-3">
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Plattform</dt>
<dd class="mt-1 text-sm text-slate-900">{{ candidate?.clipCompilationPlatform || leaderboardEntry.platform }}</dd>
</div>
</div>
</dl>
</section>
</div>
<section class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Teamnotiz</p>
<p class="mt-2 text-sm leading-6 text-slate-600">
{{ candidate?.acceptanceNote?.trim() || 'Noch keine Teamnotiz hinterlegt.' }}
</p>
</section>
</template>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Schliessen</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AdminCandidateItem, AdminVotingCandidateRank } from '../../types/awards'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
const props = defineProps<{
open: boolean
categoryName: string
groupName: string
rank: number | null
candidate: AdminCandidateItem | null
leaderboardEntry: AdminVotingCandidateRank | null
}>()
defineEmits<{
close: []
}>()
const subtitle = computed(() => {
if (!props.leaderboardEntry) return undefined
return `${props.groupName} · ${props.categoryName}`
})
const rankLabel = computed(() => props.rank ?? '')
const avgViewersLabel = computed(() => {
if (props.candidate?.avgViewers === null || props.candidate?.avgViewers === undefined) return 'Keine Daten'
return props.candidate.avgViewers.toLocaleString('de-DE')
})
function acceptanceStatusLabel(status: string | null | undefined, isAccepted: boolean) {
if (status === 'contacted') return 'Angefragt'
if (status === 'accepted') return 'Angenommen'
if (status === 'declined') return 'Abgesagt'
if (status === 'open') return 'Offen'
return isAccepted ? 'Angenommen' : 'nicht final'
}
function clipEmbedStatusLabel(candidateStatus: string | null | undefined, rankStatus: string | null | undefined) {
const value = candidateStatus || rankStatus || 'unchecked'
if (value === 'embeddable') return 'Einbettbar'
if (value === 'link_only') return 'Nur Link'
if (value === 'blocked') return 'Nicht nutzbar'
return 'Noch nicht geprueft'
}
</script>
@@ -4,7 +4,7 @@
:title="category ? `${category.categoryName} Leaderboard` : 'Leaderboard'"
:subtitle="category ? `${category.groupName} · ${viewerRangeLabel(category.viewerRangeMin, category.viewerRangeMax)}` : undefined"
size="xl"
@close="$emit('close')"
@close="closeAll"
>
<template v-if="category">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -23,7 +23,7 @@
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-violet-100">
<div class="hidden grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_180px] gap-3 bg-violet-50/70 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 lg:grid">
<div class="hidden grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_240px] gap-3 bg-violet-50/70 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 lg:grid">
<span>Rang</span>
<span>Kandidat</span>
<span>Votes</span>
@@ -35,7 +35,7 @@
<article
v-for="(candidate, index) in category.leaderboard"
:key="candidate.candidateId"
class="grid gap-3 px-4 py-4 lg:grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_180px] lg:items-center"
class="grid gap-3 px-4 py-4 lg:grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_240px] lg:items-center"
>
<div class="flex items-center gap-3 lg:block">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-100 text-sm font-black text-violet-800">{{ index + 1 }}</span>
@@ -55,22 +55,28 @@
<span class="lg:hidden text-slate-500">Anteil: </span>{{ candidate.voteSharePercent }}%
</p>
<div class="flex flex-wrap gap-2">
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
Gewinner
</span>
<span v-if="candidate.isTopTie" class="hidden rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700 lg:inline-flex">
Tie
</span>
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="candidate.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
</span>
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
Regelkonflikt
</span>
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
nicht final
</span>
<div class="flex flex-wrap items-center gap-2 lg:flex-nowrap lg:justify-between">
<div class="flex flex-wrap gap-2">
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
Gewinner
</span>
<span v-if="candidate.isTopTie" class="hidden rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700 lg:inline-flex">
Tie
</span>
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="candidate.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
</span>
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
Regelkonflikt
</span>
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
nicht final
</span>
</div>
<Button variant="ghost" size="sm" class="gap-1.5 whitespace-nowrap" @click="openCandidateDetails(candidate.candidateId)">
<Eye class="h-4 w-4" />
Details
</Button>
</div>
</article>
</div>
@@ -81,20 +87,66 @@
</div>
</template>
</Modal>
<AdminVotingCandidateDetailModal
:open="selectedCandidateId !== null"
:category-name="category?.categoryName ?? ''"
:group-name="category?.groupName ?? ''"
:rank="selectedCandidateRankIndex"
:candidate="selectedCandidate"
:leaderboard-entry="selectedCandidateRank"
@close="selectedCandidateId = null"
/>
</template>
<script setup lang="ts">
import type { AdminVotingWorkspaceRow } from './useAdminVotingManager'
import Modal from '../ui/Modal.vue'
import { computed, ref, watch } from 'vue'
import { Eye } from '@lucide/vue'
defineProps<{
import type { AdminCandidateItem } from '../../types/awards'
import AdminVotingCandidateDetailModal from './AdminVotingCandidateDetailModal.vue'
import Modal from '../ui/Modal.vue'
import Button from '../ui/Button.vue'
import type { AdminVotingWorkspaceRow } from './useAdminVotingManager'
const props = defineProps<{
category: AdminVotingWorkspaceRow | null
candidates: AdminCandidateItem[]
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
const selectedCandidateId = ref<number | null>(null)
const selectedCandidate = computed(() =>
props.candidates.find((candidate) => candidate.id === selectedCandidateId.value) ?? null,
)
const selectedCandidateRank = computed(() =>
props.category?.leaderboard.find((candidate) => candidate.candidateId === selectedCandidateId.value) ?? null,
)
const selectedCandidateRankIndex = computed(() => {
if (!props.category || selectedCandidateId.value === null) return null
const index = props.category.leaderboard.findIndex((candidate) => candidate.candidateId === selectedCandidateId.value)
return index >= 0 ? index + 1 : null
})
watch(() => props.category?.categoryId ?? null, () => {
selectedCandidateId.value = null
})
function openCandidateDetails(candidateId: number) {
selectedCandidateId.value = candidateId
}
function closeAll() {
selectedCandidateId.value = null
emit('close')
}
function viewerRangeLabel(min: number | null, max: number | null) {
if (min === null && max === null) return 'Offene Einordnung'
if (min !== null && max === null) return `Ab ${min} Viewer`
@@ -31,7 +31,7 @@
{{ group.winnerSetCount }} Gewinner
</span>
<span v-if="group.problemCount > 0" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{{ group.problemCount }} pruefen
{{ group.problemCount }} prüfen
</span>
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700">
<ChevronDown class="h-4 w-4 transition" :class="isExpanded(group.name) ? 'rotate-180' : ''" />
@@ -259,7 +259,7 @@ function buildWinnerOptions(row: AdminWinnerResultRow) {
})
return [
{ label: 'Bitte Gewinner waehlen', value: '' },
{ label: 'Bitte Gewinner wählen', value: '' },
...sortedCandidates.map((candidate) => {
const rank = rankByCandidateId.get(candidate.id)
return {
@@ -17,6 +17,8 @@ export type FaqFormItem = {
export type AdminContentForm = {
hostDisplayName: string
hostTagline: string
hostArtistName: string
hostImageUrl: string
newsletterUrl: string
shareXUrl: string
shareDiscordUrl: string
@@ -46,7 +46,7 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
{
key: 'nomination',
title: 'Nominierung',
description: 'Community schlägt Creator und Clips vor.',
description: 'Community reicht Creator-Vorschläge ein.',
start: 'nominationStartsAt',
end: 'nominationEndsAt',
editable: true,
@@ -138,7 +138,7 @@ export function useAdminAnalyticsManager() {
{
label: 'Votes pro Kandidat',
value: votesPerCandidate.toLocaleString('de-DE'),
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
note: 'Zeigt, ob die Kandidatenbasis breit genug für die aktuelle Vote-Menge ist.',
icon: Vote,
},
{
@@ -0,0 +1,248 @@
import { computed, ref } from 'vue'
import type { AdminArchivedWinnerItem, UpsertArchivedWinnerPayload } from '../../types/awards'
import { useAwardsStore } from '../../stores/awards'
interface ArchiveDraft {
localKey: string
id: number | null
year: string
category: string
subcategory: string
winnerName: string
winnerUrl: string
updatedAt: string | null
isNew: boolean
isSaving: boolean
isDeleting: boolean
}
let draftCounter = 0
export function useAdminArchiveManager() {
const store = useAwardsStore()
const loading = ref(false)
const adminError = ref('')
const adminMessage = ref('')
const drafts = ref<ArchiveDraft[]>([])
const stats = computed(() => {
const savedDrafts = drafts.value.filter((draft) => draft.id !== null)
const years = new Set(savedDrafts.map((draft) => draft.year).filter((year) => year.trim().length > 0))
const unsaved = drafts.value.filter((draft) => draft.isNew).length
return [
{
label: 'Archiv-Einträge',
value: `${savedDrafts.length}`,
note: 'Gespeicherte Gewinner für die Landingpage',
},
{
label: 'Jahre',
value: `${years.size}`,
note: 'Unabhängig vom aktiven Award-Jahr',
},
{
label: 'Neue Zeilen',
value: `${unsaved}`,
note: unsaved > 0 ? 'Noch nicht gespeichert' : 'Alles ist gespeichert',
},
]
})
const groupedDrafts = computed(() => {
const groups = new Map<string, { key: string; label: string; sortYear: number; items: ArchiveDraft[] }>()
for (const draft of [...drafts.value].sort(compareDrafts)) {
const numericYear = Number.parseInt(draft.year, 10)
const isValidYear = !draft.isNew && Number.isFinite(numericYear)
const key = isValidYear ? `year-${numericYear}` : 'new'
const existing = groups.get(key)
if (existing) {
existing.items.push(draft)
continue
}
groups.set(key, {
key,
label: isValidYear ? String(numericYear) : 'Neu',
sortYear: isValidYear ? numericYear : Number.MAX_SAFE_INTEGER,
items: [draft],
})
}
return [...groups.values()]
.sort((left, right) => right.sortYear - left.sortYear)
})
async function loadArchivedWinners() {
loading.value = true
adminError.value = ''
try {
const entries = await store.loadAdminArchivedWinners()
drafts.value = entries.map(createDraftFromItem)
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Das Archiv konnte nicht geladen werden.'
drafts.value = []
} finally {
loading.value = false
}
}
function addDraft() {
drafts.value.unshift(createEmptyDraft())
}
async function saveDraft(localKey: string) {
const draft = drafts.value.find((item) => item.localKey === localKey)
if (!draft) return
adminError.value = ''
adminMessage.value = ''
let payload: UpsertArchivedWinnerPayload
try {
payload = buildPayload(draft)
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Bitte alle Pflichtfelder prüfen.'
return
}
draft.isSaving = true
try {
if (draft.id === null) {
await store.createAdminArchivedWinner(payload)
adminMessage.value = 'Archiv-Eintrag wurde angelegt.'
} else {
await store.updateAdminArchivedWinner(draft.id, payload)
adminMessage.value = 'Archiv-Eintrag wurde gespeichert.'
}
drafts.value = store.adminArchivedWinners.map(createDraftFromItem)
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Der Archiv-Eintrag konnte nicht gespeichert werden.'
} finally {
draft.isSaving = false
}
}
async function deleteDraft(localKey: string) {
const draft = drafts.value.find((item) => item.localKey === localKey)
if (!draft) return
adminError.value = ''
adminMessage.value = ''
if (draft.id === null) {
drafts.value = drafts.value.filter((item) => item.localKey !== localKey)
return
}
if (typeof window !== 'undefined' && !window.confirm(`Archiv-Eintrag für ${draft.winnerName || draft.subcategory} wirklich löschen?`)) {
return
}
draft.isDeleting = true
try {
await store.deleteAdminArchivedWinner(draft.id)
drafts.value = store.adminArchivedWinners.map(createDraftFromItem)
adminMessage.value = 'Archiv-Eintrag wurde gelöscht.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Der Archiv-Eintrag konnte nicht gelöscht werden.'
} finally {
draft.isDeleting = false
}
}
return {
loading,
adminError,
adminMessage,
drafts,
groupedDrafts,
stats,
loadArchivedWinners,
addDraft,
saveDraft,
deleteDraft,
}
}
function createDraftFromItem(item: AdminArchivedWinnerItem): ArchiveDraft {
return {
localKey: `saved-${item.id}`,
id: item.id,
year: String(item.year),
category: item.category,
subcategory: item.subcategory,
winnerName: item.winnerName,
winnerUrl: item.winnerUrl,
updatedAt: item.updatedAt,
isNew: false,
isSaving: false,
isDeleting: false,
}
}
function createEmptyDraft(): ArchiveDraft {
draftCounter += 1
return {
localKey: `new-${draftCounter}`,
id: null,
year: '',
category: '',
subcategory: '',
winnerName: '',
winnerUrl: '',
updatedAt: null,
isNew: true,
isSaving: false,
isDeleting: false,
}
}
function buildPayload(draft: ArchiveDraft): UpsertArchivedWinnerPayload {
const year = Number.parseInt(draft.year.trim(), 10)
if (!Number.isFinite(year)) {
throw new Error('Bitte ein gültiges Jahr eintragen.')
}
const category = draft.category.trim()
const subcategory = draft.subcategory.trim()
const winnerName = draft.winnerName.trim()
const winnerUrl = draft.winnerUrl.trim()
if (!category || !subcategory || !winnerName || !winnerUrl) {
throw new Error('Jahr, Gewinner, Link, Kategorie und Unterkategorie sind Pflichtfelder.')
}
return {
year,
category,
subcategory,
winnerName,
winnerUrl,
}
}
function compareDrafts(left: ArchiveDraft, right: ArchiveDraft) {
const leftYear = Number.parseInt(left.year, 10)
const rightYear = Number.parseInt(right.year, 10)
const normalizedLeftYear = Number.isFinite(leftYear) ? leftYear : Number.MAX_SAFE_INTEGER
const normalizedRightYear = Number.isFinite(rightYear) ? rightYear : Number.MAX_SAFE_INTEGER
if (normalizedLeftYear !== normalizedRightYear) {
return normalizedRightYear - normalizedLeftYear
}
return [
left.category,
left.subcategory,
left.winnerName,
].join('\u0000').localeCompare([
right.category,
right.subcategory,
right.winnerName,
].join('\u0000'), 'de')
}
@@ -1,9 +1,10 @@
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../../lib/api'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
import type { AdminCandidateDeletePreview, AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
interface CandidateForm {
categoryId: number
@@ -64,6 +65,8 @@ export function useAdminCandidateManager() {
const editingId = ref<number | 'new' | null>(null)
const openedRouteCandidateId = ref<number | null>(null)
const candidateToDelete = ref<AdminCandidateItem | null>(null)
const deletePreviewLoading = ref(false)
const candidateDeletePreview = ref<AdminCandidateDeletePreview | null>(null)
const form = reactive<CandidateForm>({
categoryId: 0,
displayName: '',
@@ -392,7 +395,7 @@ export function useAdminCandidateManager() {
try {
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `${candidateToDelete.value.displayName}" wurde gelöscht.`
candidateToDelete.value = null
closeDeleteModal()
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
@@ -400,6 +403,27 @@ export function useAdminCandidateManager() {
}
}
async function openDelete(candidate: AdminCandidateItem) {
candidateToDelete.value = candidate
candidateDeletePreview.value = null
deletePreviewLoading.value = true
adminError.value = ''
try {
candidateDeletePreview.value = await api.getAdminCandidateDeletePreview(candidate.id)
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschvorschau konnte nicht geladen werden.'
} finally {
deletePreviewLoading.value = false
}
}
function closeDeleteModal() {
candidateToDelete.value = null
candidateDeletePreview.value = null
deletePreviewLoading.value = false
}
return {
seasonDetail,
saving,
@@ -437,9 +461,13 @@ export function useAdminCandidateManager() {
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
deletePreviewLoading,
candidateDeletePreview,
clearFilters,
openCreate,
openEdit,
openDelete,
closeDeleteModal,
handlePlatformSelection,
saveModal,
confirmDelete,
@@ -217,7 +217,7 @@ export function useAdminCategoryManager() {
editingGroupName.value === null ? 'Hauptkategorie anlegen' : 'Hauptkategorie bearbeiten',
)
const subcategoryModalTitle = computed(() =>
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufuegen' : 'Unterkategorie bearbeiten',
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufügen' : 'Unterkategorie bearbeiten',
)
const overviewCategories = computed(() => {
@@ -390,7 +390,7 @@ export function useAdminCategoryManager() {
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((draft, index) => ({ ...draft, sortOrder: index + 1 })),
})
adminMessage.value = 'Unterkategorien wurden fuer alle Hauptkategorien aktualisiert.'
adminMessage.value = 'Unterkategorien wurden für alle Hauptkategorien aktualisiert.'
closeSubcategoryConfigModal()
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Unterkategorien konnten nicht gespeichert werden.'
@@ -30,6 +30,8 @@ function createEmptyForm(): AdminContentForm {
return {
hostDisplayName: '',
hostTagline: '',
hostArtistName: '',
hostImageUrl: '/assets/amaterasu2sei_2.png',
newsletterUrl: '',
shareXUrl: '',
shareDiscordUrl: '',
@@ -43,7 +45,7 @@ function createEmptyForm(): AdminContentForm {
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
streamBannerEyebrow: 'Das grosse Finale',
streamBannerEyebrow: 'Das große Finale',
streamBannerTitle: 'Award-Show Finale',
streamBannerText: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
streamBannerLiveButtonLabel: 'Jetzt live · Zum Stream',
@@ -52,7 +54,7 @@ function createEmptyForm(): AdminContentForm {
streamBannerUseCompletedContent: false,
streamBannerCompletedEyebrow: 'Danke fürs Mitfiebern',
streamBannerCompletedTitle: 'Award-Show abgeschlossen',
streamBannerCompletedText: 'Die grosse Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
streamBannerCompletedText: 'Die große Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
streamBannerCompletedButtonLabel: 'Highlights ansehen',
streamBannerCompletedButtonUrl: '',
awardsSectionTitle: '',
@@ -106,12 +108,16 @@ export function useAdminContentManager() {
const saveError = ref('')
const privacyPreviewOpen = ref(false)
const iconUploadError = ref('')
const hostImageUploadError = ref('')
const hostImageUploading = ref(false)
watch(
() => store.adminSiteSettings,
(settings) => {
form.hostDisplayName = settings.hostDisplayName
form.hostTagline = settings.hostTagline
form.hostArtistName = settings.hostArtistName
form.hostImageUrl = settings.hostImageUrl
form.newsletterUrl = settings.newsletterUrl
form.shareXUrl = settings.shareXUrl
form.shareDiscordUrl = settings.shareDiscordUrl
@@ -290,6 +296,40 @@ export function useAdminContentManager() {
form.socialLinks[index].icon = ''
}
async function handleHostImageUpload(event: Event) {
hostImageUploadError.value = ''
saveMessage.value = ''
saveError.value = ''
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp']
if (!allowedTypes.includes(file.type)) {
hostImageUploadError.value = 'Bitte PNG, JPG oder WebP hochladen.'
return
}
const maxBytes = 8 * 1024 * 1024
if (file.size > maxBytes) {
hostImageUploadError.value = 'Hostbild ist zu groß. Maximal erlaubt sind 8 MB.'
return
}
hostImageUploading.value = true
try {
await store.uploadAdminHostImage(file)
saveMessage.value = 'Hostbild gespeichert und auf der Landingpage aktualisiert.'
} catch (error) {
hostImageUploadError.value = error instanceof Error ? error.message : 'Hostbild konnte nicht gespeichert werden.'
} finally {
hostImageUploading.value = false
}
}
async function saveSiteSettings(sectionLabel = 'Landingpage-Inhalte') {
saving.value = true
saveMessage.value = ''
@@ -299,6 +339,7 @@ export function useAdminContentManager() {
await store.updateAdminSiteSettings({
hostDisplayName: form.hostDisplayName,
hostTagline: form.hostTagline,
hostArtistName: form.hostArtistName,
newsletterUrl: form.newsletterUrl,
shareXUrl: form.shareXUrl,
shareDiscordUrl: form.shareDiscordUrl,
@@ -361,6 +402,8 @@ export function useAdminContentManager() {
saveError,
privacyPreviewOpen,
iconUploadError,
hostImageUploadError,
hostImageUploading,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
@@ -376,6 +419,7 @@ export function useAdminContentManager() {
socialSimpleIconColor,
handleSocialIconUpload,
clearSocialIcon,
handleHostImageUpload,
saveSiteSettings,
adminSiteSettings: computed(() => store.adminSiteSettings),
}
@@ -185,6 +185,7 @@ export function useAdminDashboardOverview() {
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
if (path.startsWith('/admin/archive')) return authStore.hasPermission('winners')
if (path.startsWith('/admin/clips')) return authStore.hasPermission('clips')
if (path.startsWith('/admin/users-logs')) return authStore.hasPermission('audit')
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
@@ -139,9 +139,6 @@ export function useAdminReviewsManager(options?: {
? (seasonDetail.value.trackingReviewNotes ?? '')
: '',
)
const selectedNominationHasBlockingFlag = computed(() =>
selectedNomination.value?.trackingFlags?.some((flag) => flag.blocksApproval) ?? false,
)
const selectedRelatedPendingNominations = computed<AdminNominationReviewItem[]>(() => {
if (!selectedNomination.value) return []
const nominationIds = new Set(selectedNomination.value.nominationIds)
@@ -153,7 +150,6 @@ export function useAdminReviewsManager(options?: {
if (!selectedNomination.value) return false
const form = reviewForms[selectedNomination.value.id]
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim() && form.categoryId)
&& !(selectedNominationHasBlockingFlag.value && selectedNomination.value.trackingReviewStatus !== 'overridden')
})
function focusNominationFromRoute() {
@@ -444,7 +440,6 @@ export function useAdminReviewsManager(options?: {
selectedRelatedPendingNominations,
selectedNominationSignalSummary,
trackingReviewNotes,
selectedNominationHasBlockingFlag,
canApproveSelected,
approveNomination,
rejectNomination,
@@ -119,7 +119,7 @@ export function useAdminSeasonManager() {
)
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
const latestSeasonAuditSummary = computed(() =>
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion fuer dieses Jahr gefunden.',
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion für dieses Jahr gefunden.',
)
const latestSeasonAuditMeta = computed(() => {
const entry = latestSeasonAuditEntry.value
@@ -530,7 +530,7 @@ function buildReadinessItems(
? 'Alle Kategorien haben Kandidaten.'
: candidateBlocking
? `${facts.emptyCategories} Kategorien brauchen noch Kandidaten.`
: 'In der Nominierung duerfen Kategorien noch leer sein.',
: 'In der Nominierung dürfen Kategorien noch leer sein.',
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && activeCandidateCount > 0,
blocking: candidateBlocking,
to: '/admin/candidates',
@@ -559,7 +559,7 @@ function buildReadinessItems(
? 'Alle Gewinner sind vergeben.'
: winnerBlocking
? `${facts.missingResults} Kategorien brauchen vor Abschluss einen Gewinner.`
: 'Vor Abschluss muessen alle Gewinner gesetzt sein.',
: 'Vor Abschluss müssen alle Gewinner gesetzt sein.',
complete: detail.categories.length > 0 && facts.missingResults === 0,
blocking: winnerBlocking,
to: '/admin/analytics',
@@ -606,7 +606,7 @@ function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
}
if (phaseKey !== 'nomination') {
issues.push('Direkt public ist fuer neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
issues.push('Direkt public ist für neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
}
return issues
@@ -57,7 +57,7 @@ function normalizeFlagRule(rule: AdminTrackingFlagRule): AdminTrackingFlagRule {
enabled: Boolean(rule.enabled),
autoTriggerEnabled: Boolean(rule.autoTriggerEnabled),
requiresManualReview: Boolean(rule.requiresManualReview),
blocksApproval: Boolean(rule.blocksApproval),
blocksApproval: false,
adminNoteRequiredOnOverride: Boolean(rule.adminNoteRequiredOnOverride),
severity: rule.severity === 'high' || rule.severity === 'low' ? rule.severity : 'medium',
}
@@ -47,6 +47,7 @@ export function useAdminVotingManager() {
const focusedCategoryId = ref<number | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const seasonCandidates = computed(() => seasonDetail.value.candidates)
const votingWorkspace = computed(() => seasonDetail.value.votingWorkspace)
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
@@ -73,7 +74,7 @@ export function useAdminVotingManager() {
statusTone = 'border-emerald-100 bg-emerald-50 text-emerald-700'
} else if (isProblem) {
statusKey = 'issues'
statusLabel = 'Pruefen'
statusLabel = 'Prüfen'
statusTone = 'border-amber-100 bg-amber-50 text-amber-700'
} else if (category.winnerReady) {
statusKey = 'ready'
@@ -192,7 +193,7 @@ export function useAdminVotingManager() {
{
label: 'Vorbereitung',
value: String(votingWorkspace.value.summary.readySubcategories),
note: 'Unterkategorien sind fuer Gewinner vorbereitet',
note: 'Unterkategorien sind für Gewinner vorbereitet',
icon: Trophy,
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
@@ -213,7 +214,7 @@ export function useAdminVotingManager() {
const statusFilters = computed(() => [
{ value: allFilter, label: 'Alle', count: workspaceRows.value.length },
{ value: 'issues', label: 'Pruefen', count: workspaceRows.value.filter((row) => row.isProblem).length },
{ value: 'issues', label: 'Prüfen', count: workspaceRows.value.filter((row) => row.isProblem).length },
{ value: 'ready', label: 'Bereit', count: workspaceRows.value.filter((row) => row.winnerReady).length },
{ value: 'winner', label: 'Gewinner gesetzt', count: workspaceRows.value.filter((row) => row.hasWinner).length },
{ value: 'idle', label: 'Noch ohne Votes', count: workspaceRows.value.filter((row) => row.voteCount === 0).length },
@@ -281,6 +282,7 @@ export function useAdminVotingManager() {
statusFilter,
statusFilters,
summaryCards,
seasonCandidates,
votingGroups,
selectedGroup,
selectedGroupName,
@@ -471,7 +471,7 @@ export function useAdminWinnersManager() {
{
label: 'Reviews',
value: String(reviewWarningCount.value),
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe prüfen.',
icon: Clock3,
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
progress: null,
@@ -59,7 +59,7 @@ function initialsFor(value: string) {
<component
:is="winner.url ? 'a' : 'article'"
v-for="winner in props.selectedArchive.winners"
:key="`${props.selectedArchive.year}-${winner.category}`"
:key="`${props.selectedArchive.year}-${winner.category}-${winner.subcategory}-${winner.name}`"
:href="winner.url || undefined"
:target="winner.url ? '_blank' : undefined"
:rel="winner.url ? 'noopener' : undefined"
@@ -12,7 +12,7 @@
<span style="position:absolute;top:230px;left:62%;width:8px;height:8px;border-radius:50%;background:#c9b6f0;opacity:.7;"></span>
<span style="position:absolute;bottom:120px;left:84%;font-size:14px;color:#e7b13e;animation:twinkle 3.1s ease-in-out 1.4s infinite;"></span>
<img class="home-hero__character" src="/assets/jayu-hero.png" alt="Jayu mit Stern-Pokal" data-hero-char style="position:absolute;top:-10px;right:max(30px,calc(50% - 560px));height:1010px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);" />
<img class="home-hero__character" :src="siteContent.hostImageUrl || '/assets/amaterasu2sei_2.png'" alt="Host der VTuber Star Awards" data-hero-char style="position:absolute;top:16px;right:max(-70px,calc(50% - 660px));height:1040px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 52%,rgba(0,0,0,0) 83%);mask-image:linear-gradient(to bottom,#000 52%,rgba(0,0,0,0) 83%);" />
<div class="home-hero__veil" style="position:absolute;inset:0;z-index:2;pointer-events:none;background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.86) 26%,rgba(246,240,254,.3) 46%,transparent 62%);"></div>
<div class="home-hero__content" style="position:relative;z-index:3;max-width:1200px;margin:0 auto;padding:60px 24px 70px;">
@@ -95,6 +95,10 @@
<div style="font-size:12px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:5px;">Host</div>
<div class="home-hero__host-name" style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-weight:700;font-size:27px;letter-spacing:.5px;color:#5f44ad;line-height:1;margin-bottom:5px;">{{ siteContent.hostDisplayName.toUpperCase() }} <span style="font-size:19px;color:#e7b13e;"></span></div>
<div style="font-size:14px;color:#8a8398;margin-bottom:14px;">{{ siteContent.hostTagline }}</div>
<div v-if="siteContent.hostArtistName" style="display:inline-flex;align-items:center;gap:7px;margin:-2px 0 14px;padding:7px 10px;border-radius:999px;background:rgba(241,236,251,.74);font-size:12px;font-weight:700;letter-spacing:.04em;color:#7b68ad;">
<span style="color:#e7b13e;"></span>
Art by {{ siteContent.hostArtistName }}
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a
v-for="social in hostSocialLinks"
@@ -138,6 +142,8 @@ interface HomeSocialLink {
interface HomeSiteContent {
hostDisplayName: string
hostTagline: string
hostArtistName: string
hostImageUrl: string
}
defineProps<{
@@ -14,6 +14,8 @@ interface HomeSocialLink {
interface HomeSiteContent {
hostDisplayName: string
hostTagline: string
hostArtistName: string
hostImageUrl: string
}
defineProps<{
@@ -56,7 +56,6 @@ const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomQuery = ref('')
const nominationDrafts = ref<Record<number, string[]>>({})
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
const nominationReviewMode = ref(false)
watch(
@@ -99,39 +98,25 @@ function notifyClipCategoryChange(value: number) {
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] ?? []
const limit = nominationLinkLimitFor(option.id)
nextDrafts[option.id] = createNominationLinkSlots(existing, limit)
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
nextVisibleLinkCounts[option.id] = Math.min(limit, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
}
nominationDrafts.value = nextDrafts
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
}
function nominationLinksFor(categoryIndex: number) {
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
}
function nominationVisibleLinkCountFor(categoryIndex: number) {
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
}
function compactNominationCategory(categoryIndex: number) {
const limit = nominationLinkLimitFor(categoryIndex)
const links = nominationLinksFor(categoryIndex)
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, limit)
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
const nextVisibleCount = Math.max(1, compactedLinks.length)
nominationDrafts.value = {
...nominationDrafts.value,
[categoryIndex]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[categoryIndex]: nextVisibleCount,
[categoryIndex]: createNominationLinkSlots(compactedLinks, limit),
}
}
@@ -163,9 +148,6 @@ const activeNominationCategory = computed(() =>
)
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
const activeNominationLinkLimit = computed(() => nominationLinkLimitFor(nominationCatValue.value))
const activeNominationRemainingLinks = computed(() => Math.max(0, activeNominationLinkLimit.value - activeNominationVisibleLinkCount.value))
const nominationReviewItems = computed(() =>
props.catOptions
.map((option) => ({
@@ -239,36 +221,6 @@ function editNominationCategory(categoryIndex: number) {
nominationReviewMode.value = false
}
function addNominationLinkField() {
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
const limit = nominationLinkLimitFor(nominationCatValue.value)
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: Math.min(limit, 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, nominationLinkLimitFor(nominationCatValue.value))
const limit = nominationLinkLimitFor(nominationCatValue.value)
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
const nextVisibleCount = Math.max(1, Math.min(limit, 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({
@@ -371,11 +323,7 @@ function stripCategoryIcon(value: string) {
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
:links="activeNominationLinks"
:errors="activeNominationErrors"
:visible-link-count="activeNominationVisibleLinkCount"
:remaining-link-count="activeNominationRemainingLinks"
:on-link-input="onNominationLinkInput"
:on-add-link-field="addNominationLinkField"
:on-remove-link-field="removeNominationLinkField"
/>
</main>
</div>
@@ -3,11 +3,7 @@ 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>
@@ -20,17 +16,9 @@ const props = defineProps<{
</header>
<div class="home-nomination-pane__fields">
<label v-for="(_, index) in props.links.slice(0, props.visibleLinkCount)" :key="index" class="home-nomination-field">
<label v-for="(_, index) in props.links" :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]"
@@ -44,16 +32,5 @@ const props = defineProps<{
<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 hinzufügen</span>
<small>Noch {{ props.remainingLinkCount }} möglich</small>
</button>
</section>
</template>
@@ -53,6 +53,12 @@ const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.va
const safeNewsletterUrl = computed(() =>
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
)
const safeShareXUrl = computed(() =>
isSafePublicUrl(props.siteContent.shareXUrl) ? props.siteContent.shareXUrl : '',
)
const safeShareDiscordUrl = computed(() =>
isSafePublicUrl(props.siteContent.shareDiscordUrl) ? props.siteContent.shareDiscordUrl : '',
)
const isSponsorFooterModal = computed(() => activeFooterLink.value?.key === 'sponsors')
const footerModalEyebrow = computed(() =>
isSponsorFooterModal.value ? 'Support the show' : 'Footer Seite',
@@ -164,8 +170,8 @@ onUnmounted(() => {
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
<a v-if="props.siteContent.shareXUrl" :href="props.siteContent.shareXUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" 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 v-if="props.siteContent.shareDiscordUrl" :href="props.siteContent.shareDiscordUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
<a v-if="safeShareXUrl" :href="safeShareXUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" 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 v-if="safeShareDiscordUrl" :href="safeShareDiscordUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
<a href="#" @click.prevent="copyLink" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" 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>{{ linkCopied ? 'Kopiert ✓' : 'Link kopieren' }}</a>
</div>
</div>
@@ -703,47 +703,6 @@ html,body{margin:0;padding:0;background:#160a26;}
font-weight:800;
}
.home-nomination-pane__add-link{
width:100%;
margin-top:14px;
padding:13px 16px;
border:1px dashed #cdbcf0;
border-radius:16px;
background:linear-gradient(135deg,rgba(255,255,255,.9),rgba(246,240,254,.88));
color:#7658c4;
cursor:pointer;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
font-family:'Outfit',sans-serif;
text-align:left;
transition:transform .16s ease,border-color .16s ease,box-shadow .16s ease,background .16s ease;
}
.home-nomination-pane__add-link:hover{
border-color:#a98ddb;
background:#fff;
box-shadow:0 12px 28px rgba(82,61,128,.09);
transform:translateY(-1px);
}
.home-nomination-pane__add-link span{
font-size:14px;
font-weight:900;
}
.home-nomination-pane__add-link small{
flex:none;
padding:4px 8px;
border-radius:999px;
background:#f0e8fb;
color:#7d60c6;
font-size:11px;
font-weight:900;
line-height:1;
}
.home-nomination-review__hero h4,
.home-missing-votes h4{
margin:0 0 8px;
@@ -2249,11 +2208,6 @@ html,body{margin:0;padding:0;background:#160a26;}
min-height:0!important;
}
.home-nomination-pane__add-link{
align-items:flex-start!important;
flex-direction:column!important;
}
.home-nomination-review__stats{
grid-template-columns:1fr!important;
}
+23
View File
@@ -1,6 +1,8 @@
import type {
AdminAuditEntriesResponse,
AdminArchivedWinnerItem,
AdminAuditQueryOptions,
AdminCandidateDeletePreview,
AdminDashboardResponse,
AdminOptionalFeatureSettingsResponse,
AdminOperationalSettingsResponse,
@@ -43,6 +45,7 @@ import type {
UpdateTeamRolesPayload,
UpdateWorkflowRulesPayload,
UpsertCandidatePayload,
UpsertArchivedWinnerPayload,
UpsertCategoryPayload,
UpsertCategoryGroupPayload,
UpsertSponsorPayload,
@@ -139,7 +142,25 @@ export const adminApi = {
requestJson<{ saved: boolean; sponsor: AdminSponsorItem }>(`/api/admin/sponsors/${sponsorId}`, jsonRequest('PUT', payload)),
deleteAdminSponsor: (sponsorId: number) =>
requestJson<{ deleted: boolean; sponsorId: number }>(`/api/admin/sponsors/${sponsorId}`, { method: 'DELETE' }),
getAdminArchivedWinners: () => requestJson<AdminArchivedWinnerItem[]>('/api/admin/archived-winners'),
createAdminArchivedWinner: (payload: UpsertArchivedWinnerPayload) =>
requestJson<{ saved: boolean; entry: AdminArchivedWinnerItem }>('/api/admin/archived-winners', jsonRequest('POST', payload)),
updateAdminArchivedWinner: (archivedWinnerId: number, payload: UpsertArchivedWinnerPayload) =>
requestJson<{ saved: boolean; entry: AdminArchivedWinnerItem }>(
`/api/admin/archived-winners/${archivedWinnerId}`,
jsonRequest('PUT', payload),
),
deleteAdminArchivedWinner: (archivedWinnerId: number) =>
requestJson<{ deleted: boolean; archivedWinnerId: number }>(`/api/admin/archived-winners/${archivedWinnerId}`, { method: 'DELETE' }),
getAdminSiteSettings: () => requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings'),
uploadAdminHostImage: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings/host-image', {
method: 'POST',
body: formData,
})
},
getAdminOptionalFeatureSettings: () =>
requestJson<AdminOptionalFeatureSettingsResponse>('/api/admin/optional-feature-settings'),
updateAdminOptionalFeatureSettings: (payload: UpdateOptionalFeatureSettingsPayload) =>
@@ -204,6 +225,8 @@ export const adminApi = {
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, jsonRequest('PUT', payload)),
getAdminCandidateDeletePreview: (candidateId: number) =>
requestJson<AdminCandidateDeletePreview>(`/api/admin/candidates/${candidateId}/delete-preview`),
deleteAdminCandidate: (candidateId: number) =>
requestJson<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, {
method: 'DELETE',
+6
View File
@@ -39,6 +39,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminCandidatesView.vue'),
meta: { keepAlive: true },
},
{
path: 'archive',
name: 'admin-archive',
component: () => import('../views/admin/AdminArchiveView.vue'),
meta: { keepAlive: true },
},
{
path: 'clips',
name: 'admin-clips',
+27
View File
@@ -20,6 +20,7 @@ import {
import type {
ApproveNominationPayload,
AddNominationLinkBlacklistEntryPayload,
UpsertArchivedWinnerPayload,
CreateSeasonPayload,
CreateClipPayload,
CreateNominationPayload,
@@ -150,6 +151,7 @@ export const useAwardsStore = defineStore('awards', {
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
this.adminArchivedWinners = []
this.adminRiskHistory = []
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
@@ -215,6 +217,7 @@ export const useAwardsStore = defineStore('awards', {
this.adminSponsors = []
this.adminShowactApplications = []
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
this.adminArchivedWinners = []
}
},
async initializeAdminWorkspace() {
@@ -316,6 +319,25 @@ export const useAwardsStore = defineStore('awards', {
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
return result
},
async loadAdminArchivedWinners() {
this.adminArchivedWinners = await api.getAdminArchivedWinners()
return this.adminArchivedWinners
},
async createAdminArchivedWinner(payload: UpsertArchivedWinnerPayload) {
const result = await api.createAdminArchivedWinner(payload)
await Promise.all([this.loadAdminArchivedWinners(), this.loadHomeData()])
return result
},
async updateAdminArchivedWinner(archivedWinnerId: number, payload: UpsertArchivedWinnerPayload) {
const result = await api.updateAdminArchivedWinner(archivedWinnerId, payload)
await Promise.all([this.loadAdminArchivedWinners(), this.loadHomeData()])
return result
},
async deleteAdminArchivedWinner(archivedWinnerId: number) {
const result = await api.deleteAdminArchivedWinner(archivedWinnerId)
await Promise.all([this.loadAdminArchivedWinners(), this.loadHomeData()])
return result
},
async updateAdminSponsor(sponsorId: number, seasonId: number, payload: UpsertSponsorPayload) {
const result = await api.updateAdminSponsor(sponsorId, payload)
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
@@ -561,6 +583,11 @@ export const useAwardsStore = defineStore('awards', {
await this.loadHomeData()
return result
},
async uploadAdminHostImage(file: File) {
this.adminSiteSettings = await api.uploadAdminHostImage(file)
await this.loadHomeData()
return this.adminSiteSettings
},
},
})
+8
View File
@@ -1,6 +1,7 @@
import { ApiRequestError } from '../../lib/http'
import type {
AdminDashboardResponse,
AdminArchivedWinnerItem,
AdminOptionalFeatureSettingsResponse,
AdminOperationalSettingsResponse,
AdminRiskFlag,
@@ -39,6 +40,8 @@ export function createEmptyOverview(): OverviewResponse {
siteContent: {
hostDisplayName: '',
hostTagline: '',
hostArtistName: '',
hostImageUrl: '/assets/amaterasu2sei_2.png',
newsletterUrl: '',
shareXUrl: '',
shareDiscordUrl: '',
@@ -220,6 +223,8 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
return {
hostDisplayName: '',
hostTagline: '',
hostArtistName: '',
hostImageUrl: '/assets/amaterasu2sei_2.png',
newsletterUrl: '',
shareXUrl: '',
shareDiscordUrl: '',
@@ -297,6 +302,7 @@ export function createAwardsState() {
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
adminArchivedWinners: [] as AdminArchivedWinnerItem[],
adminSponsors: [] as AdminSponsorItem[],
adminShowactApplications: [] as AdminShowactApplicationItem[],
adminRiskHistory: [] as AdminRiskFlag[],
@@ -331,6 +337,8 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
streamerIdentityId: candidate.streamerIdentityId ?? null,
avgViewers: candidate.avgViewers ?? null,
votes: candidate.votes ?? 0,
nominationTally: candidate.nominationTally ?? 0,
})),
pendingNominations: detail.pendingNominations ?? [],
+23
View File
@@ -264,6 +264,8 @@ export interface AdminCandidateItem {
displayName: string
channelSlug: string
platform: string
avgViewers: number | null
votes: number
nominationTally: number
acceptanceStatus: string
acceptanceNote: string | null
@@ -273,6 +275,14 @@ export interface AdminCandidateItem {
clipEmbedStatus: string
}
export interface AdminCandidateDeletePreview {
candidateId: number
nominationCount: number
clipCount: number
voteCount: number
resultCount: number
}
export interface AdminAwardResultItem {
id: number
categoryId: number
@@ -453,6 +463,8 @@ export interface AdminSeasonDetailResponse {
export interface AdminSiteSettingsResponse {
hostDisplayName: string
hostTagline: string
hostArtistName: string
hostImageUrl: string
newsletterUrl: string
shareXUrl: string
shareDiscordUrl: string
@@ -533,6 +545,17 @@ export interface AdminSponsorItem {
isVisible: boolean
}
export interface AdminArchivedWinnerItem {
id: number
year: number
category: string
subcategory: string
winnerName: string
winnerUrl: string
createdAt: string
updatedAt: string | null
}
export interface AdminShowactApplicationItem {
id: number
seasonId: number
+9
View File
@@ -244,6 +244,14 @@ export interface SetAwardResultPayload {
candidateId: number
}
export interface UpsertArchivedWinnerPayload {
year: number
category: string
subcategory: string
winnerName: string
winnerUrl: string
}
export interface ApproveNominationPayload {
displayName: string
channelSlug: string
@@ -276,6 +284,7 @@ export interface AddNominationLinkBlacklistEntryPayload {
export interface UpdateSiteSettingsPayload {
hostDisplayName: string
hostTagline: string
hostArtistName: string
newsletterUrl: string
shareXUrl: string
shareDiscordUrl: string
+2
View File
@@ -72,6 +72,8 @@ export interface PublicStreamBannerContent {
export interface PublicSiteContent {
hostDisplayName: string
hostTagline: string
hostArtistName: string
hostImageUrl: string
newsletterUrl: string
shareXUrl: string
shareDiscordUrl: string
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { Archive, Link as LinkIcon, Plus, Save, Trash2 } from '@lucide/vue'
import { onMounted } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { useAdminArchiveManager } from '../../components/admin/useAdminArchiveManager'
const {
loading,
adminError,
adminMessage,
groupedDrafts,
stats,
loadArchivedWinners,
addDraft,
saveDraft,
deleteDraft,
} = useAdminArchiveManager()
watchAdminToast(adminMessage, adminError)
onMounted(() => {
void loadArchivedWinners()
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Archiv"
description="Historische Gewinner für die Landingpage unabhängig vom aktiven Award-Jahr pflegen. Änderungen landen direkt in der öffentlichen Archivquelle."
:icon="Archive"
/>
<section class="grid gap-3 sm:grid-cols-3">
<Card v-for="card in stats" :key="card.label" class="p-4">
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">{{ card.label }}</p>
<strong class="mt-2 block text-2xl text-slate-950">{{ card.value }}</strong>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ card.note }}</p>
</Card>
</section>
<Card class="p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-950">Archiv-Gewinner verwalten</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">
Pro Zeile kannst du Jahr, Gewinner, Link, Kategorie und Unterkategorie anlegen, anpassen oder löschen.
</p>
</div>
<Button class="gap-2" @click="addDraft">
<Plus class="h-4 w-4" />
Neue Zeile
</Button>
</div>
</Card>
<div v-if="loading" class="rounded-[26px] border border-dashed border-violet-200 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
Archiv wird geladen ...
</div>
<div v-else-if="groupedDrafts.length === 0" class="rounded-[26px] border border-dashed border-violet-200 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
Noch keine Archiv-Gewinner vorhanden.
</div>
<section v-else class="space-y-5">
<div
v-for="group in groupedDrafts"
:key="group.key"
class="space-y-3"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-600">Archiv</p>
<h2 class="text-2xl font-bold text-slate-950">{{ group.label }}</h2>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
{{ group.items.length }} Einträge
</span>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<Card
v-for="draft in group.items"
:key="draft.localKey"
class="p-5"
>
<div class="grid gap-4">
<div class="grid gap-4 sm:grid-cols-[120px_minmax(0,1fr)]">
<label class="grid min-w-0 gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Jahr</span>
<input
v-model="draft.year"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="4"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="2025"
>
</label>
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Gewinner</span>
<input
v-model="draft.winnerName"
type="text"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name des Gewinners"
>
</label>
</div>
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Link</span>
<div class="relative">
<LinkIcon class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="draft.winnerUrl"
type="url"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://twitch.tv/..."
>
</div>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Kategorie</span>
<input
v-model="draft.category"
type="text"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Rising Stars"
>
</label>
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Unterkategorie</span>
<input
v-model="draft.subcategory"
type="text"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Aufbau"
>
</label>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-violet-100 pt-4">
<p class="text-xs text-slate-400">
{{ draft.updatedAt ? `Zuletzt aktualisiert: ${new Date(draft.updatedAt).toLocaleString('de-DE')}` : draft.isNew ? 'Neue Archivzeile' : 'Noch nicht aktualisiert' }}
</p>
<div class="flex flex-wrap gap-2">
<Button
variant="ghost"
class="gap-2"
:disabled="draft.isDeleting || draft.isSaving"
@click="deleteDraft(draft.localKey)"
>
<Trash2 class="h-4 w-4" />
{{ draft.isDeleting ? 'Löscht ...' : 'Löschen' }}
</Button>
<Button
class="gap-2"
:disabled="draft.isDeleting || draft.isSaving"
@click="saveDraft(draft.localKey)"
>
<Save class="h-4 w-4" />
{{ draft.isSaving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
</div>
</div>
</Card>
</div>
</div>
</section>
</div>
</template>
@@ -25,7 +25,6 @@ const {
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateIdentitySummaries,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
@@ -48,9 +47,13 @@ const {
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
deletePreviewLoading,
candidateDeletePreview,
clearFilters,
openCreate,
openEdit,
openDelete,
closeDeleteModal,
handlePlatformSelection,
saveModal,
confirmDelete,
@@ -149,11 +152,10 @@ watchAdminToast(adminMessage, adminError)
:range-end="rangeEnd"
:category-label-map="categoryLabelMap"
:duplicate-candidate-keys="duplicateCandidateKeys"
:candidate-identity-summaries="candidateIdentitySummaries"
:candidate-workflow-notices="candidateWorkflowNotices"
:acceptance-status-options="acceptanceStatusOptions"
@edit="openEdit"
@delete="candidateToDelete = $event"
@delete="openDelete"
@update:page="page = $event"
@open-create="openCreate"
/>
@@ -189,8 +191,10 @@ watchAdminToast(adminMessage, adminError)
<AdminCandidateDeleteModal
:candidate="candidateToDelete"
:preview="candidateDeletePreview"
:loading="deletePreviewLoading"
:deleting="deleting"
@close="candidateToDelete = null"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
</div>
+11 -1
View File
@@ -32,6 +32,8 @@ const {
saveError,
privacyPreviewOpen,
iconUploadError,
hostImageUploadError,
hostImageUploading,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
@@ -47,6 +49,7 @@ const {
socialSimpleIconColor,
handleSocialIconUpload,
clearSocialIcon,
handleHostImageUpload,
saveSiteSettings,
adminSiteSettings,
} = useAdminContentManager()
@@ -164,7 +167,14 @@ function closeContentEditor() {
:icon="FileText"
/>
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentBasicsSection
:form="form"
:saving="saving"
:host-image-uploading="hostImageUploading"
:host-image-upload-error="hostImageUploadError"
:handle-host-image-upload="handleHostImageUpload"
:save-site-settings="saveSiteSettings"
/>
<section aria-labelledby="content-editor-title" class="space-y-4">
<div>
@@ -66,6 +66,7 @@ const fullNavGroups = computed(() => [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Archiv', to: '/admin/archive', description: 'Historische Gewinner pflegen', icon: Trophy, permission: 'winners', badge: () => store.adminArchivedWinners.length > 0 ? `${store.adminArchivedWinners.length}` : null },
...(clipAdminMenuVisible.value ? [{
label: 'Clips',
to: '/admin/clips',
+83 -25
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { Check, KeyRound, LockKeyhole, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminTeamRolePermissionsModal from '../../components/admin/AdminTeamRolePermissionsModal.vue'
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
import { watchAdminToast } from '../../composables/useAdminToast'
@@ -22,7 +21,6 @@ const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authSt
const passwordModalOpen = ref(false)
const createMemberModalOpen = ref(false)
const editMemberModalOpen = ref(false)
const rolePermissionsModalOpen = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
const ownPasswordForm = reactive({
@@ -132,6 +130,10 @@ function canDeleteMember(member: AdminTeamMember) {
return true
}
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
}
function openCreateMemberModal() {
startCreateMember()
createMemberModalOpen.value = true
@@ -281,16 +283,6 @@ async function changeOwnPassword() {
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
Aktualisieren
</Button>
<Button
size="sm"
type="button"
variant="ghost"
class="border border-violet-200 bg-violet-50 text-violet-700 shadow-sm shadow-violet-200/40 hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-100 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/60"
@click="rolePermissionsModalOpen = true"
>
<ShieldCheck class="mr-2 h-4 w-4" />
Rollenrechte
</Button>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
@@ -377,18 +369,84 @@ async function changeOwnPassword() {
</div>
</Card>
<AdminTeamRolePermissionsModal
:open="rolePermissionsModalOpen"
:saving="saving"
:has-role-changes="hasRoleChanges"
:roles="roles"
:permissions="permissions"
:permission-groups="permissionGroups"
:role-has-permission="roleHasPermission"
:set-role-permission="setRolePermission"
:save-role-permissions="saveRolePermissions"
@close="rolePermissionsModalOpen = false"
/>
<Card class="p-4 md:p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<ShieldCheck class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
<p class="mt-1 text-xs font-semibold text-slate-500">
Alle Rollen bleiben direkt vergleichbar, ohne zwischen Einzelansichten zu wechseln.
</p>
</div>
</div>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
{{ saving ? 'Speichert...' : 'Speichern' }}
</Button>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
<tr>
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
<div class="flex flex-col items-center gap-1">
<span>{{ role.label }}</span>
<span
v-if="role.key === 'owner' || role.key === 'creator'"
class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] text-slate-500"
>
fixiert
</span>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white/70">
<tr v-for="permission in permissions" :key="permission.key">
<td class="sticky left-0 z-10 max-w-[280px] bg-white px-3 py-3 align-top">
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
<span class="mt-1 block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
<span class="mt-2 inline-flex rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold text-slate-500">
{{ permission.menuPath }}
</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
<label class="inline-flex cursor-pointer items-center justify-center" :class="role.key === 'owner' || role.key === 'creator' ? 'cursor-not-allowed' : ''">
<input
class="sr-only"
type="checkbox"
:checked="roleHasPermission(role.key, permission.key)"
:disabled="role.key === 'owner' || role.key === 'creator'"
:aria-label="`${role.label}: ${permission.label}`"
@change="handlePermissionChange(role.key, permission.key, $event)"
>
<span
class="inline-flex h-9 min-w-20 items-center justify-center gap-2 rounded-xl border px-3 text-sm font-bold transition"
:class="roleHasPermission(role.key, permission.key)
? role.key === 'owner' || role.key === 'creator'
? 'border-slate-200 bg-slate-100 text-slate-500'
: 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20'
: role.key === 'owner' || role.key === 'creator'
? 'border-slate-200 bg-slate-50 text-slate-300'
: 'border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
>
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-4 w-4" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
{{ roleHasPermission(role.key, permission.key) ? 'An' : 'Aus' }}
</span>
</label>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
<Teleport to="body">
<div
@@ -77,7 +77,7 @@ function windowSupportText(rule: AdminTrackingMetricRule) {
.map((value) => windowOptions.find((item) => item.value === value)?.label ?? value)
.join(', ')
return `Dieses Zeitfenster ist aktuell nicht automatisch verfuegbar. Auto geht nur fuer: ${labels || 'keine Zeitfenster'}.`
return `Dieses Zeitfenster ist aktuell nicht automatisch verfügbar. Auto geht nur für: ${labels || 'keine Zeitfenster'}.`
}
function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'optional') {
@@ -93,9 +93,9 @@ function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'op
}
if (rule.sourceSupport === 'auto') {
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstuetzt wird.')
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstützt wird.')
} else if (rule.sourceSupport === 'manual') {
parts.push('Diese Metrik ist fuer manuelle Pflege gedacht.')
parts.push('Diese Metrik ist für manuelle Pflege gedacht.')
} else {
parts.push('Diese Metrik dient nur als Review-Kontext.')
}
@@ -174,7 +174,7 @@ function activateOptionalMetric(ruleKey: string) {
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Tracking Source</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">TwitchTracker API-Quelle</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Diese Base URL wird fuer automatische TwitchTracker-Lookups verwendet.
Diese Base URL wird für automatische TwitchTracker-Lookups verwendet.
</p>
</div>
<Button class="gap-2" :disabled="trackingLoading || sourceSaving || !hasUnsavedTrackingSourceChanges || !canManageTrackingRules" @click="saveTrackingSource">
@@ -210,9 +210,9 @@ function activateOptionalMetric(ruleKey: string) {
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Manual Review Notes</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Admin-Notizen fuer Fallback-Quellen</h2>
<h2 class="mt-1 text-xl font-bold text-slate-900">Admin-Notizen für Fallback-Quellen</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Diese Notizen erscheinen im Nominierungsreview als Hilfe fuer den manuellen Check.
Diese Notizen erscheinen im Nominierungsreview als Hilfe für den manuellen Check.
</p>
</div>
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
@@ -247,7 +247,7 @@ function activateOptionalMetric(ruleKey: string) {
rows="10"
:disabled="!canManageTrackingRules"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50"
placeholder="- SullyGnome Channel Summary&#10;- manueller Check fuer Category Fit&#10;- Sonderregeln fuer kleine Kanaele"
placeholder="- SullyGnome Channel Summary&#10;- manueller Check für Category Fit&#10;- Sonderregeln für kleine Kanäle"
/>
</section>
</Card>
@@ -296,7 +296,7 @@ function activateOptionalMetric(ruleKey: string) {
{{ sourceSupportLabel(rule.sourceSupport) }}
</span>
<span v-if="rule.requiredForAutoClassification" class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">
Pflicht fuer Auto-Check
Pflicht für Auto-Check
</span>
<span v-if="rule.showInReview" class="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
Im Review sichtbar
@@ -328,7 +328,7 @@ function activateOptionalMetric(ruleKey: string) {
</label>
<AdminSettingsToggle
:model-value="rule.requiredForAutoClassification"
:label="`${rule.label} fuer Auto-Check erzwingen`"
:label="`${rule.label} für Auto-Check erzwingen`"
:disabled="!canManageTrackingRules"
active-label="Pflicht"
inactive-label="Nicht Pflicht"
@@ -373,7 +373,7 @@ function activateOptionalMetric(ruleKey: string) {
<Card class="overflow-visible">
<section class="border-b border-violet-100 p-5">
<h2 class="text-xl font-bold text-slate-900">Optionale Metriken</h2>
<p class="mt-2 text-sm text-slate-500">Nur aktive optionale Metriken koennen im Nominierungsreview erscheinen. Deaktivierte Metriken werden dort komplett ausgeblendet.</p>
<p class="mt-2 text-sm text-slate-500">Nur aktive optionale Metriken können im Nominierungsreview erscheinen. Deaktivierte Metriken werden dort komplett ausgeblendet.</p>
</section>
<section class="space-y-4 p-5">
@@ -425,8 +425,8 @@ function activateOptionalMetric(ruleKey: string) {
<div v-if="isTopCategoriesRule(rule)" class="mt-4 space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
<div>
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext fuer Top Categories</p>
<p class="mt-1 text-sm text-slate-600">Diese Einstellungen erzeugen aktuell keinen automatischen TwitchTracker-Abgleich, steuern aber den manuellen Review-Rahmen fuer Admins.</p>
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext für Top Categories</p>
<p class="mt-1 text-sm text-slate-600">Diese Einstellungen erzeugen aktuell keinen automatischen TwitchTracker-Abgleich, steuern aber den manuellen Review-Rahmen für Admins.</p>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@@ -555,6 +555,7 @@ function activateOptionalMetric(ruleKey: string) {
<Card class="overflow-visible">
<section class="border-b border-violet-100 p-5">
<h2 class="text-xl font-bold text-slate-900">Flags</h2>
<p class="mt-2 text-sm text-slate-500">Tracking-Flags markieren Review-Faelle und Hinweise, blockieren aber keine Annahme oder Verwerfung mehr.</p>
</section>
<section class="grid gap-3 p-5">
<article v-for="rule in trackingFlags" :key="rule.key" class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
@@ -599,23 +600,20 @@ function activateOptionalMetric(ruleKey: string) {
:model-value="rule.requiresManualReview"
:label="`${rule.label} braucht Review`"
:disabled="!canManageTrackingRules"
active-label="Review noetig"
active-label="Review nötig"
inactive-label="Nur Hinweis"
@update:model-value="updateTrackingFlag(rule.key, { requiresManualReview: $event })"
/>
<AdminSettingsToggle
:model-value="rule.blocksApproval"
:label="`${rule.label} blockiert Approve`"
:disabled="!canManageTrackingRules"
active-label="Blockiert"
inactive-label="Warnt nur"
@update:model-value="updateTrackingFlag(rule.key, { blocksApproval: $event })"
/>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Approve-Verhalten</p>
<p class="mt-2 text-sm font-semibold text-slate-900">Warnt nur</p>
<p class="mt-1 text-xs leading-5 text-slate-500">Tracking-Flags sind reine Zusatzinfos für den Review-Kontext und sperren die Entscheidung nicht.</p>
</div>
<AdminSettingsToggle
:model-value="rule.adminNoteRequiredOnOverride"
:label="`${rule.label} braucht Override-Notiz`"
:disabled="!canManageTrackingRules"
active-label="Notiz noetig"
active-label="Notiz nötig"
inactive-label="Optional"
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
/>
@@ -16,6 +16,7 @@ const {
statusFilter,
statusFilters,
summaryCards,
seasonCandidates,
votingGroups,
selectedGroupName,
focusedCategoryId,
@@ -117,6 +118,7 @@ function openLeaderboard(category: AdminVotingWorkspaceRow) {
<AdminVotingLeaderboardModal
:category="leaderboardCategory"
:candidates="seasonCandidates"
@close="leaderboardCategory = null"
/>
</div>
@@ -43,7 +43,7 @@ watchAdminToast(adminMessage, adminError)
<div class="space-y-6">
<AdminPageHeader
eyebrow="Gewinner"
description="Finale Gewinner pro Unterkategorie setzen, Guards pruefen und Landingpage-Freigabe vorbereiten."
description="Finale Gewinner pro Unterkategorie setzen, Guards prüfen und Landingpage-Freigabe vorbereiten."
:icon="Trophy"
/>