Add winner archive, host image upload and live demo data
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:
@@ -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)
|
public static string NormalizeSeasonStreamUrl(string? value)
|
||||||
{
|
{
|
||||||
var trimmed = value?.Trim() ?? string.Empty;
|
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 DisplayName,
|
||||||
string ChannelSlug,
|
string ChannelSlug,
|
||||||
string Platform,
|
string Platform,
|
||||||
|
int? AvgViewers,
|
||||||
|
int Votes,
|
||||||
int NominationTally,
|
int NominationTally,
|
||||||
string AcceptanceStatus,
|
string AcceptanceStatus,
|
||||||
string? AcceptanceNote,
|
string? AcceptanceNote,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ namespace Backend.Contracts;
|
|||||||
public sealed record AdminSiteSettingsResponse(
|
public sealed record AdminSiteSettingsResponse(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string HostImageUrl,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
string ShareXUrl,
|
string ShareXUrl,
|
||||||
string ShareDiscordUrl,
|
string ShareDiscordUrl,
|
||||||
@@ -41,6 +43,7 @@ public sealed record AdminSiteSettingsResponse(
|
|||||||
public sealed record UpdateSiteSettingsRequest(
|
public sealed record UpdateSiteSettingsRequest(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
string ShareXUrl,
|
string ShareXUrl,
|
||||||
string ShareDiscordUrl,
|
string ShareDiscordUrl,
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ public sealed record PublicStreamBannerContentDto(
|
|||||||
public sealed record PublicSiteContentDto(
|
public sealed record PublicSiteContentDto(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
string HostTagline,
|
string HostTagline,
|
||||||
|
string HostArtistName,
|
||||||
|
string HostImageUrl,
|
||||||
string NewsletterUrl,
|
string NewsletterUrl,
|
||||||
string ShareXUrl,
|
string ShareXUrl,
|
||||||
string ShareDiscordUrl,
|
string ShareDiscordUrl,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<Candidate> Candidates => Set<Candidate>();
|
public DbSet<Candidate> Candidates => Set<Candidate>();
|
||||||
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
|
||||||
public DbSet<AwardResult> Results => Set<AwardResult>();
|
public DbSet<AwardResult> Results => Set<AwardResult>();
|
||||||
|
public DbSet<ArchivedWinner> ArchivedWinners => Set<ArchivedWinner>();
|
||||||
public DbSet<Nomination> Nominations => Set<Nomination>();
|
public DbSet<Nomination> Nominations => Set<Nomination>();
|
||||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||||
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
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.HostDisplayName).HasMaxLength(120);
|
||||||
entity.Property(item => item.HostTagline).HasMaxLength(160);
|
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.NewsletterUrl).HasMaxLength(400);
|
||||||
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
|
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
|
||||||
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
|
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);
|
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 =>
|
modelBuilder.Entity<UserSession>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasIndex(item => item.SessionToken).IsUnique();
|
entity.HasIndex(item => item.SessionToken).IsUnique();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ public sealed class SiteSettings
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string HostDisplayName { get; set; } = string.Empty;
|
public string HostDisplayName { get; set; } = string.Empty;
|
||||||
public string HostTagline { 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 NewsletterUrl { get; set; } = string.Empty;
|
||||||
public string ShareXUrl { get; set; } = string.Empty;
|
public string ShareXUrl { get; set; } = string.Empty;
|
||||||
public string ShareDiscordUrl { get; set; } = string.Empty;
|
public string ShareDiscordUrl { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
group.MapAdminDashboardEndpoints();
|
group.MapAdminDashboardEndpoints();
|
||||||
group.MapAdminSeasonManagementEndpoints();
|
group.MapAdminSeasonManagementEndpoints();
|
||||||
|
group.MapAdminArchiveEndpoints();
|
||||||
group.MapAdminModerationEndpoints();
|
group.MapAdminModerationEndpoints();
|
||||||
group.MapAdminExtrasEndpoints();
|
group.MapAdminExtrasEndpoints();
|
||||||
group.MapAdminTeamEndpoints();
|
group.MapAdminTeamEndpoints();
|
||||||
|
|||||||
@@ -28,13 +28,6 @@ public static partial class AdminModerationEndpoints
|
|||||||
return Results.NotFound();
|
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);
|
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||||
|
|||||||
@@ -193,6 +193,34 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
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(
|
private static async Task<IResult> DeleteCandidate(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
int candidateId,
|
int candidateId,
|
||||||
@@ -206,6 +234,38 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
return Results.NotFound();
|
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);
|
db.Candidates.Remove(candidate);
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -213,11 +273,29 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
"candidate",
|
"candidate",
|
||||||
candidate.Id.ToString(),
|
candidate.Id.ToString(),
|
||||||
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
|
$"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));
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
await db.SaveChangesAsync(context.RequestAborted);
|
try
|
||||||
return Results.Ok(new { deleted = true, candidateId });
|
{
|
||||||
|
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)
|
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);
|
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
var trackingRules = TrackingRulesSettings.Read(settings);
|
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||||
|
|
||||||
var candidates = await db.Candidates
|
var candidateRows = await db.Candidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId)
|
.Where(item => item.SeasonId == seasonId)
|
||||||
.OrderBy(item => item.DisplayName)
|
.OrderBy(item => item.DisplayName)
|
||||||
.Select(item => new AdminCandidateItemDto(
|
.Select(item => new AdminCandidateRow(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.StreamerIdentityId,
|
item.StreamerIdentityId,
|
||||||
@@ -43,7 +43,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.ClipEmbedStatus))
|
item.ClipEmbedStatus))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
var candidateCounts = candidates
|
var candidateCounts = candidateRows
|
||||||
.GroupBy(item => item.CategoryId)
|
.GroupBy(item => item.CategoryId)
|
||||||
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
|
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
|
||||||
|
|
||||||
@@ -217,6 +217,11 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.CandidateId))
|
item.CandidateId))
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
var candidates = BuildCandidateItems(
|
||||||
|
candidateRows,
|
||||||
|
pendingNominationRows,
|
||||||
|
reviewedNominationRows,
|
||||||
|
votingEntryRows);
|
||||||
var votingWorkspace = BuildVotingWorkspace(
|
var votingWorkspace = BuildVotingWorkspace(
|
||||||
categories,
|
categories,
|
||||||
candidates,
|
candidates,
|
||||||
@@ -281,6 +286,21 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
int CategoryId,
|
int CategoryId,
|
||||||
int CandidateId);
|
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(
|
private sealed record AdminNominationRow(
|
||||||
int Id,
|
int Id,
|
||||||
int? CategoryId,
|
int? CategoryId,
|
||||||
@@ -314,6 +334,79 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
string? ReviewedByTwitchId,
|
string? ReviewedByTwitchId,
|
||||||
DateTimeOffset? ReviewedAt);
|
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(
|
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||||
AdminNominationRow item,
|
AdminNominationRow item,
|
||||||
IEnumerable<dynamic> categoryRows,
|
IEnumerable<dynamic> categoryRows,
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
.WithName("UpdateAdminCandidate")
|
.WithName("UpdateAdminCandidate")
|
||||||
.WithOpenApi();
|
.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)
|
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||||
.WithName("DeleteAdminCandidate")
|
.WithName("DeleteAdminCandidate")
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
private const string FallbackMaintenanceTitle = "Sternenpause";
|
private const string FallbackMaintenanceTitle = "Sternenpause";
|
||||||
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||||
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
|
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)
|
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
@@ -25,6 +34,10 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
|
||||||
.WithName("UpdateAdminSiteSettings")
|
.WithName("UpdateAdminSiteSettings")
|
||||||
.WithOpenApi();
|
.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)
|
group.MapGet("/operational-settings", GetOperationalSettings)
|
||||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||||
.WithName("GetAdminOperationalSettings")
|
.WithName("GetAdminOperationalSettings")
|
||||||
@@ -68,43 +81,76 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new AdminSiteSettingsResponse(
|
return Results.Ok(MapSiteSettingsResponse(settings));
|
||||||
settings.HostDisplayName,
|
}
|
||||||
settings.HostTagline,
|
|
||||||
settings.NewsletterUrl,
|
private static async Task<IResult> UploadHostImage(
|
||||||
settings.ShareXUrl,
|
HttpContext context,
|
||||||
settings.ShareDiscordUrl,
|
AwardsDbContext db,
|
||||||
settings.PrivacyEmail,
|
IAdminAuditService adminAuditService)
|
||||||
settings.PrivacyPolicyContent,
|
{
|
||||||
settings.PrivacyPolicyUpdatedBy,
|
if (!context.Request.HasFormContentType)
|
||||||
settings.PrivacyPolicyUpdatedAt,
|
{
|
||||||
settings.ImprintUrl,
|
return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." });
|
||||||
settings.ImprintContent,
|
}
|
||||||
settings.ContactUrl,
|
|
||||||
settings.ContactContent,
|
var form = await context.Request.ReadFormAsync(context.RequestAborted);
|
||||||
settings.SponsorsUrl,
|
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
|
||||||
settings.SponsorsContent,
|
if (file is null || file.Length == 0)
|
||||||
settings.ShowactsUrl,
|
{
|
||||||
settings.ShowactsContent,
|
return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." });
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
|
}
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
|
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
|
if (file.Length > MaxHostImageBytes)
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
|
{
|
||||||
settings.StreamBannerLiveButtonUrl,
|
return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." });
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
|
}
|
||||||
settings.StreamBannerUseCompletedContent,
|
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
|
if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension))
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
|
{
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
|
return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." });
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
|
}
|
||||||
settings.StreamBannerCompletedButtonUrl,
|
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
|
var extension = Path.GetExtension(file.FileName);
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
|
if (!string.IsNullOrWhiteSpace(extension)
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
|
&& !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
|
&& !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
|
||||||
SeasonMappings.ReadSocialLinks(settings),
|
&& string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)))
|
||||||
SeasonMappings.ReadFaqItems(settings),
|
{
|
||||||
settings.ShowactFormSchemaJson ?? "[]"));
|
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(
|
private static async Task<IResult> UpdateSiteSettings(
|
||||||
@@ -129,6 +175,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
|
|
||||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||||
settings.HostTagline = request.HostTagline.Trim();
|
settings.HostTagline = request.HostTagline.Trim();
|
||||||
|
settings.HostArtistName = request.HostArtistName.Trim();
|
||||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||||
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
settings.ShareXUrl = normalizedUrls.ShareXUrl;
|
||||||
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
|
||||||
@@ -189,6 +236,61 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.Ok(new { saved = true });
|
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(
|
private static IResult? NormalizeSiteSettingsUrls(
|
||||||
UpdateSiteSettingsRequest request,
|
UpdateSiteSettingsRequest request,
|
||||||
out PublicSiteUrlSettings normalizedUrls,
|
out PublicSiteUrlSettings normalizedUrls,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public static partial class PublicEndpoints
|
|||||||
.WithName("GetSiteStatus")
|
.WithName("GetSiteStatus")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapGet("/host-image", GetHostImage)
|
||||||
|
.WithName("GetPublicHostImage")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
|
group.MapGet("/seasons/{year:int}/categories", GetSeasonCategories)
|
||||||
.WithName("GetSeasonCategories")
|
.WithName("GetSeasonCategories")
|
||||||
.WithOpenApi();
|
.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)
|
.OrderByDescending(item => item.Year)
|
||||||
.ToArrayAsync();
|
.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
|
var archiveYears = archiveYearRows
|
||||||
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
|
.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();
|
.ToArray();
|
||||||
|
|
||||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
@@ -150,6 +165,8 @@ public static partial class PublicEndpoints
|
|||||||
new PublicSiteContentDto(
|
new PublicSiteContentDto(
|
||||||
siteSettings.HostDisplayName,
|
siteSettings.HostDisplayName,
|
||||||
siteSettings.HostTagline,
|
siteSettings.HostTagline,
|
||||||
|
siteSettings.HostArtistName,
|
||||||
|
AdminSiteSettingsEndpoints.BuildHostImageUrl(siteSettings),
|
||||||
siteSettings.NewsletterUrl,
|
siteSettings.NewsletterUrl,
|
||||||
siteSettings.ShareXUrl,
|
siteSettings.ShareXUrl,
|
||||||
siteSettings.ShareDiscordUrl,
|
siteSettings.ShareDiscordUrl,
|
||||||
|
|||||||
@@ -9,6 +9,42 @@ public static partial class PublicEndpoints
|
|||||||
{
|
{
|
||||||
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
|
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
|
var season = await db.Seasons
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.Year == year)
|
.Where(item => item.Year == year)
|
||||||
@@ -16,7 +52,7 @@ public static partial class PublicEndpoints
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (season is null)
|
if (season is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (season.WinnersPublishedAt is null)
|
if (season.WinnersPublishedAt is null)
|
||||||
@@ -24,11 +60,6 @@ public static partial class PublicEndpoints
|
|||||||
return Results.Ok(new WinnerArchiveResponse(year, []));
|
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)
|
if (latestPublishedWinnerYear == season.Year)
|
||||||
{
|
{
|
||||||
return Results.Ok(new WinnerArchiveResponse(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1620
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';
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1620
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");
|
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 =>
|
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -779,11 +824,26 @@ namespace Backend.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("HostArtistName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
b.Property<string>("HostDisplayName")
|
b.Property<string>("HostDisplayName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("character varying(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")
|
b.Property<string>("HostTagline")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(160)
|
.HasMaxLength(160)
|
||||||
|
|||||||
@@ -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(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(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(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(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(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),
|
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
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [];
|
return (JsonSerializer.Deserialize<TrackingFlagHit[]>(json, JsonOptions) ?? [])
|
||||||
|
.Select(flag => flag with { BlocksApproval = false })
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -333,7 +335,7 @@ public static class TrackingRulesSettings
|
|||||||
Severity = severity,
|
Severity = severity,
|
||||||
AutoTriggerEnabled = stored.AutoTriggerEnabled,
|
AutoTriggerEnabled = stored.AutoTriggerEnabled,
|
||||||
RequiresManualReview = stored.RequiresManualReview,
|
RequiresManualReview = stored.RequiresManualReview,
|
||||||
BlocksApproval = stored.BlocksApproval,
|
BlocksApproval = false,
|
||||||
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
|
AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ and smaller Vue files split by feature.
|
|||||||
|
|
||||||
| Data | Owner | Notes |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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>
|
<template>
|
||||||
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
|
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
|
||||||
<div class="flex items-start gap-4">
|
<div class="space-y-4">
|
||||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
<div class="flex items-start gap-4">
|
||||||
<TriangleAlert class="h-6 w-6" />
|
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||||
</span>
|
<TriangleAlert class="h-6 w-6" />
|
||||||
<p class="text-sm leading-7 text-slate-600">
|
</span>
|
||||||
„<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
<p class="text-sm leading-7 text-slate-600">
|
||||||
Das lässt sich nicht rückgängig machen.
|
„<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
<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' }}
|
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,12 +53,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TriangleAlert } from '@lucide/vue'
|
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 Button from '../ui/Button.vue'
|
||||||
import Modal from '../ui/Modal.vue'
|
import Modal from '../ui/Modal.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
candidate: AdminCandidateItem | null
|
candidate: AdminCandidateItem | null
|
||||||
|
preview: AdminCandidateDeletePreview | null
|
||||||
|
loading: boolean
|
||||||
deleting: 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">
|
<section v-if="identitySummary || ruleNotices.length" class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Regel-Kontext</p>
|
<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>
|
||||||
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-4">
|
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-3">
|
||||||
<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">Kandidaturen</p>
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
<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>
|
||||||
<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">Gewinner</p>
|
<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.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>
|
|
||||||
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.nominationTally }}</strong>
|
<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>
|
</div>
|
||||||
<div v-if="ruleNotices.length" class="mt-3 space-y-2">
|
<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 font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||||
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
{{ candidate.nominationTally }} Viewer-Nominierungen
|
{{ avgViewerLabel(candidate.avgViewers) }}
|
||||||
<template v-if="candidateIdentitySummaries[identityKey(candidate)]">
|
· {{ candidate.nominationTally.toLocaleString('de-DE') }} {{ candidate.nominationTally === 1 ? 'Nominierung' : 'Nominierungen' }}
|
||||||
· {{ candidateIdentitySummaries[identityKey(candidate)].appearances }} Kandidaturen
|
· {{ candidate.votes.toLocaleString('de-DE') }} {{ candidate.votes === 1 ? 'Vote' : 'Votes' }}
|
||||||
· {{ candidateIdentitySummaries[identityKey(candidate)].winnerPlacements }} Gewinnerplätze
|
|
||||||
</template>
|
|
||||||
</p>
|
</p>
|
||||||
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||||
Mögliches Duplikat in dieser Kategorie
|
Mögliches Duplikat in dieser Kategorie
|
||||||
@@ -129,7 +127,6 @@ const props = defineProps<{
|
|||||||
rangeEnd: number
|
rangeEnd: number
|
||||||
categoryLabelMap: Record<number, string>
|
categoryLabelMap: Record<number, string>
|
||||||
duplicateCandidateKeys: Map<string, number>
|
duplicateCandidateKeys: Map<string, number>
|
||||||
candidateIdentitySummaries: Record<string, { appearances: number; acceptedAppearances: number; winnerPlacements: number; nominationTally: number }>
|
|
||||||
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
|
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
|
||||||
acceptanceStatusOptions: Array<{ label: string; value: string; description: 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
|
|| (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) {
|
function acceptanceLabel(value: string) {
|
||||||
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
|
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
|
||||||
}
|
}
|
||||||
@@ -172,4 +160,10 @@ function embedLabel(value: string) {
|
|||||||
if (value === 'blocked') return 'Nicht nutzbar'
|
if (value === 'blocked') return 'Nicht nutzbar'
|
||||||
return 'Embed prüfen'
|
return 'Embed prüfen'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function avgViewerLabel(value: number | null) {
|
||||||
|
return value != null
|
||||||
|
? `Ø ${value.toLocaleString('de-DE')} Viewer`
|
||||||
|
: 'Tracker offen'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
:open="open"
|
:open="open"
|
||||||
size="lg"
|
size="lg"
|
||||||
:title="title"
|
: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')"
|
@close="$emit('close')"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
:value="form.description"
|
: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"
|
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"
|
@input="form.description = ($event.target as HTMLTextAreaElement).value"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
:open="open"
|
:open="open"
|
||||||
size="xl"
|
size="xl"
|
||||||
title="Unterkategorien konfigurieren"
|
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')"
|
@close="$emit('close')"
|
||||||
>
|
>
|
||||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
|
<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 class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-slate-900">Globale Unterkategorien</p>
|
<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>
|
</div>
|
||||||
<Button class="gap-2" @click="$emit('add-subcategory')">
|
<Button class="gap-2" @click="$emit('add-subcategory')">
|
||||||
<PlusCircle class="h-4 w-4" />
|
<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">
|
<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" />
|
<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>
|
<p class="mt-1 text-sm leading-6 text-slate-500">Bearbeite eine bestehende Sektion oder lege eine neue Viewer-Sektion an.</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -17,11 +17,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 p-6 md:grid-cols-2">
|
<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">
|
<label class="space-y-2">
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Name</span>
|
<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" />
|
<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>
|
||||||
<label class="space-y-2">
|
<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>
|
<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" />
|
<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>
|
</label>
|
||||||
@@ -42,7 +76,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Globe2, Save } from '@lucide/vue'
|
import { Globe2, Save, Upload } from '@lucide/vue'
|
||||||
|
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import Card from '../ui/Card.vue'
|
import Card from '../ui/Card.vue'
|
||||||
@@ -51,6 +85,9 @@ import type { AdminContentForm } from './adminContentTypes'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
form: AdminContentForm
|
form: AdminContentForm
|
||||||
saving: boolean
|
saving: boolean
|
||||||
|
hostImageUploading: boolean
|
||||||
|
hostImageUploadError: string
|
||||||
|
handleHostImageUpload: (event: Event) => Promise<void>
|
||||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const showactStatusText = computed(() => {
|
|||||||
if (settingsForm.showactApplicationsOpenNow) {
|
if (settingsForm.showactApplicationsOpenNow) {
|
||||||
return parts.length > 0
|
return parts.length > 0
|
||||||
? `Bewerbungen sind aktuell offen. ${parts.join(' · ')}.`
|
? `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) {
|
if (parts.length > 0) {
|
||||||
@@ -470,7 +470,7 @@ onMounted(loadLandingExtras)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="store.adminShowactApplications.length === 0" class="p-4 text-sm text-slate-500">
|
<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>
|
||||||
<div v-else class="divide-y divide-violet-100">
|
<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">
|
<article v-for="application in store.adminShowactApplications" :key="application.id" class="space-y-3 p-4">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
v-model="form.streamBannerEyebrow"
|
v-model="form.streamBannerEyebrow"
|
||||||
type="text"
|
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"
|
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>
|
||||||
<label class="space-y-2">
|
<label class="space-y-2">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async function saveBlacklist() {
|
|||||||
<Modal
|
<Modal
|
||||||
:open="open"
|
:open="open"
|
||||||
title="Link-Blacklist"
|
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"
|
size="lg"
|
||||||
@close="emit('close')"
|
@close="emit('close')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const props = defineProps<{
|
|||||||
platforms: string[]
|
platforms: string[]
|
||||||
} | null
|
} | null
|
||||||
trackingReviewNotes?: string
|
trackingReviewNotes?: string
|
||||||
selectedNominationHasBlockingFlag?: boolean
|
|
||||||
streamUrl?: string
|
streamUrl?: string
|
||||||
canApproveSelected: boolean
|
canApproveSelected: boolean
|
||||||
blacklistSaving: boolean
|
blacklistSaving: boolean
|
||||||
@@ -109,7 +108,6 @@ onBeforeUnmount(() => {
|
|||||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||||
:signal-summary="signalSummary"
|
:signal-summary="signalSummary"
|
||||||
:tracking-review-notes="trackingReviewNotes"
|
:tracking-review-notes="trackingReviewNotes"
|
||||||
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
|
|
||||||
:stream-url="streamUrl"
|
:stream-url="streamUrl"
|
||||||
:can-approve-selected="canApproveSelected"
|
:can-approve-selected="canApproveSelected"
|
||||||
:blacklist-saving="blacklistSaving"
|
:blacklist-saving="blacklistSaving"
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ function toneClasses(tone: AdminSettingsTone) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
|
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import NativeSelect from '../ui/NativeSelect.vue'
|
import NativeSelect from '../ui/NativeSelect.vue'
|
||||||
@@ -25,7 +26,6 @@ const props = defineProps<{
|
|||||||
platforms: string[]
|
platforms: string[]
|
||||||
} | null
|
} | null
|
||||||
trackingReviewNotes?: string
|
trackingReviewNotes?: string
|
||||||
selectedNominationHasBlockingFlag?: boolean
|
|
||||||
streamUrl?: string
|
streamUrl?: string
|
||||||
canApproveSelected: boolean
|
canApproveSelected: boolean
|
||||||
blacklistSaving: boolean
|
blacklistSaving: boolean
|
||||||
@@ -159,12 +159,23 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
|||||||
class="rounded-[24px] border p-4"
|
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'"
|
: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">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
|
<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">
|
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -172,7 +183,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
|||||||
:key="flag.key"
|
:key="flag.key"
|
||||||
class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-semibold text-amber-800"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,7 +201,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<strong class="mt-1 block text-sm text-violet-900">{{ metric.value }}</strong>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -271,12 +282,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
|||||||
</p>
|
</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">
|
<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'">
|
Anzeigename, Handle, Plattform und Tier sind Pflicht.
|
||||||
Mindestens ein Tracking-Flag blockiert die Freigabe. Bitte zuerst einen Override setzen.
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
Anzeigename, Handle, Plattform und Tier sind Pflicht.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ const {
|
|||||||
selectedRelatedPendingNominations,
|
selectedRelatedPendingNominations,
|
||||||
selectedNominationSignalSummary,
|
selectedNominationSignalSummary,
|
||||||
trackingReviewNotes,
|
trackingReviewNotes,
|
||||||
selectedNominationHasBlockingFlag,
|
|
||||||
canApproveSelected,
|
canApproveSelected,
|
||||||
approveNomination,
|
approveNomination,
|
||||||
rejectNomination,
|
rejectNomination,
|
||||||
@@ -130,7 +129,6 @@ watchAdminToast(adminMessage, adminError)
|
|||||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||||
:signal-summary="selectedNominationSignalSummary"
|
:signal-summary="selectedNominationSignalSummary"
|
||||||
:tracking-review-notes="trackingReviewNotes"
|
:tracking-review-notes="trackingReviewNotes"
|
||||||
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
|
|
||||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||||
:can-approve-selected="canApproveSelected"
|
:can-approve-selected="canApproveSelected"
|
||||||
:blacklist-saving="blacklistSaving"
|
: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 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="avgViewerLabel(nomination)" class="rounded-full border border-sky-100 bg-sky-50 px-2.5 py-1 text-sky-700">{{ avgViewerLabel(nomination) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="nomination.trackingFlags.some((flag) => flag.blocksApproval)"
|
v-if="nomination.trackingFlags.length > 0"
|
||||||
class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-rose-700"
|
class="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
|
||||||
>
|
>
|
||||||
Blockierende Flags
|
Tracking-Hinweise
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="nomination.requiresManualReview"
|
v-else-if="nomination.requiresManualReview"
|
||||||
@@ -102,7 +102,7 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
|
|||||||
v-if="!nomination.suggestedCategoryName"
|
v-if="!nomination.suggestedCategoryName"
|
||||||
class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-600"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs font-medium text-slate-400">
|
<p class="mt-2 text-xs font-medium text-slate-400">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const props = defineProps<{
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<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>
|
</div>
|
||||||
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
|
<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>
|
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ defineProps<{
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ function removeOption(field: ShowactFormField, optionIndex: number) {
|
|||||||
|
|
||||||
function choiceFieldHint(field: ShowactFormField) {
|
function choiceFieldHint(field: ShowactFormField) {
|
||||||
if (field.type === 'select') return 'Wird auf der Landingpage als Dropdown mit genau einer Auswahl gezeigt.'
|
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 === '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 fuer ein gemeinsames Feld gerendert.'
|
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.'
|
if (field.type === 'checkbox') return 'Jede Option wird als eigene benennbare Checkbox auf der Landingpage angezeigt.'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -487,7 +487,7 @@ function choiceFieldHint(field: ShowactFormField) {
|
|||||||
@close="previewOpen = false"
|
@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">
|
<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>
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@@ -521,7 +521,7 @@ function choiceFieldHint(field: ShowactFormField) {
|
|||||||
disabled
|
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"
|
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>
|
<option v-for="option in field.options" :key="option">{{ option }}</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'radio' || field.type === 'multiselect'" class="mt-3 space-y-2">
|
<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'"
|
:title="category ? `${category.categoryName} Leaderboard` : 'Leaderboard'"
|
||||||
:subtitle="category ? `${category.groupName} · ${viewerRangeLabel(category.viewerRangeMin, category.viewerRangeMax)}` : undefined"
|
:subtitle="category ? `${category.groupName} · ${viewerRangeLabel(category.viewerRangeMin, category.viewerRangeMax)}` : undefined"
|
||||||
size="xl"
|
size="xl"
|
||||||
@close="$emit('close')"
|
@close="closeAll"
|
||||||
>
|
>
|
||||||
<template v-if="category">
|
<template v-if="category">
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 overflow-hidden rounded-2xl border border-violet-100">
|
<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>Rang</span>
|
||||||
<span>Kandidat</span>
|
<span>Kandidat</span>
|
||||||
<span>Votes</span>
|
<span>Votes</span>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<article
|
<article
|
||||||
v-for="(candidate, index) in category.leaderboard"
|
v-for="(candidate, index) in category.leaderboard"
|
||||||
:key="candidate.candidateId"
|
: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">
|
<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>
|
<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 }}%
|
<span class="lg:hidden text-slate-500">Anteil: </span>{{ candidate.voteSharePercent }}%
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap items-center gap-2 lg:flex-nowrap lg:justify-between">
|
||||||
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
|
<div class="flex flex-wrap gap-2">
|
||||||
Gewinner
|
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
|
||||||
</span>
|
Gewinner
|
||||||
<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">
|
</span>
|
||||||
Tie
|
<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">
|
||||||
</span>
|
Tie
|
||||||
<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'">
|
</span>
|
||||||
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
|
<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'">
|
||||||
</span>
|
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
|
||||||
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
|
</span>
|
||||||
Regelkonflikt
|
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
|
||||||
</span>
|
Regelkonflikt
|
||||||
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
</span>
|
||||||
nicht final
|
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
||||||
</span>
|
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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,20 +87,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<AdminVotingCandidateDetailModal
|
||||||
|
:open="selectedCandidateId !== null"
|
||||||
|
:category-name="category?.categoryName ?? ''"
|
||||||
|
:group-name="category?.groupName ?? ''"
|
||||||
|
:rank="selectedCandidateRankIndex"
|
||||||
|
:candidate="selectedCandidate"
|
||||||
|
:leaderboard-entry="selectedCandidateRank"
|
||||||
|
@close="selectedCandidateId = null"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminVotingWorkspaceRow } from './useAdminVotingManager'
|
import { computed, ref, watch } from 'vue'
|
||||||
import Modal from '../ui/Modal.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
|
category: AdminVotingWorkspaceRow | null
|
||||||
|
candidates: AdminCandidateItem[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
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) {
|
function viewerRangeLabel(min: number | null, max: number | null) {
|
||||||
if (min === null && max === null) return 'Offene Einordnung'
|
if (min === null && max === null) return 'Offene Einordnung'
|
||||||
if (min !== null && max === null) return `Ab ${min} Viewer`
|
if (min !== null && max === null) return `Ab ${min} Viewer`
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
{{ group.winnerSetCount }} Gewinner
|
{{ group.winnerSetCount }} Gewinner
|
||||||
</span>
|
</span>
|
||||||
<span v-if="group.problemCount > 0" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
<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>
|
||||||
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700">
|
<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' : ''" />
|
<ChevronDown class="h-4 w-4 transition" :class="isExpanded(group.name) ? 'rotate-180' : ''" />
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ function buildWinnerOptions(row: AdminWinnerResultRow) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Bitte Gewinner waehlen', value: '' },
|
{ label: 'Bitte Gewinner wählen', value: '' },
|
||||||
...sortedCandidates.map((candidate) => {
|
...sortedCandidates.map((candidate) => {
|
||||||
const rank = rankByCandidateId.get(candidate.id)
|
const rank = rankByCandidateId.get(candidate.id)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type FaqFormItem = {
|
|||||||
export type AdminContentForm = {
|
export type AdminContentForm = {
|
||||||
hostDisplayName: string
|
hostDisplayName: string
|
||||||
hostTagline: string
|
hostTagline: string
|
||||||
|
hostArtistName: string
|
||||||
|
hostImageUrl: string
|
||||||
newsletterUrl: string
|
newsletterUrl: string
|
||||||
shareXUrl: string
|
shareXUrl: string
|
||||||
shareDiscordUrl: string
|
shareDiscordUrl: string
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
|
|||||||
{
|
{
|
||||||
key: 'nomination',
|
key: 'nomination',
|
||||||
title: 'Nominierung',
|
title: 'Nominierung',
|
||||||
description: 'Community schlägt Creator und Clips vor.',
|
description: 'Community reicht Creator-Vorschläge ein.',
|
||||||
start: 'nominationStartsAt',
|
start: 'nominationStartsAt',
|
||||||
end: 'nominationEndsAt',
|
end: 'nominationEndsAt',
|
||||||
editable: true,
|
editable: true,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function useAdminAnalyticsManager() {
|
|||||||
{
|
{
|
||||||
label: 'Votes pro Kandidat',
|
label: 'Votes pro Kandidat',
|
||||||
value: votesPerCandidate.toLocaleString('de-DE'),
|
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,
|
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 { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { api } from '../../lib/api'
|
||||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
import type { AdminCandidateDeletePreview, AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
||||||
|
|
||||||
interface CandidateForm {
|
interface CandidateForm {
|
||||||
categoryId: number
|
categoryId: number
|
||||||
@@ -64,6 +65,8 @@ export function useAdminCandidateManager() {
|
|||||||
const editingId = ref<number | 'new' | null>(null)
|
const editingId = ref<number | 'new' | null>(null)
|
||||||
const openedRouteCandidateId = ref<number | null>(null)
|
const openedRouteCandidateId = ref<number | null>(null)
|
||||||
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
||||||
|
const deletePreviewLoading = ref(false)
|
||||||
|
const candidateDeletePreview = ref<AdminCandidateDeletePreview | null>(null)
|
||||||
const form = reactive<CandidateForm>({
|
const form = reactive<CandidateForm>({
|
||||||
categoryId: 0,
|
categoryId: 0,
|
||||||
displayName: '',
|
displayName: '',
|
||||||
@@ -392,7 +395,7 @@ export function useAdminCandidateManager() {
|
|||||||
try {
|
try {
|
||||||
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
|
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
|
||||||
adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.`
|
adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.`
|
||||||
candidateToDelete.value = null
|
closeDeleteModal()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||||
} finally {
|
} 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 {
|
return {
|
||||||
seasonDetail,
|
seasonDetail,
|
||||||
saving,
|
saving,
|
||||||
@@ -437,9 +461,13 @@ export function useAdminCandidateManager() {
|
|||||||
clipEmbedStatusOptions,
|
clipEmbedStatusOptions,
|
||||||
readinessFilterOptions,
|
readinessFilterOptions,
|
||||||
candidateToDelete,
|
candidateToDelete,
|
||||||
|
deletePreviewLoading,
|
||||||
|
candidateDeletePreview,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
|
openDelete,
|
||||||
|
closeDeleteModal,
|
||||||
handlePlatformSelection,
|
handlePlatformSelection,
|
||||||
saveModal,
|
saveModal,
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export function useAdminCategoryManager() {
|
|||||||
editingGroupName.value === null ? 'Hauptkategorie anlegen' : 'Hauptkategorie bearbeiten',
|
editingGroupName.value === null ? 'Hauptkategorie anlegen' : 'Hauptkategorie bearbeiten',
|
||||||
)
|
)
|
||||||
const subcategoryModalTitle = computed(() =>
|
const subcategoryModalTitle = computed(() =>
|
||||||
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufuegen' : 'Unterkategorie bearbeiten',
|
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufügen' : 'Unterkategorie bearbeiten',
|
||||||
)
|
)
|
||||||
|
|
||||||
const overviewCategories = computed(() => {
|
const overviewCategories = computed(() => {
|
||||||
@@ -390,7 +390,7 @@ export function useAdminCategoryManager() {
|
|||||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||||
.map((draft, index) => ({ ...draft, sortOrder: index + 1 })),
|
.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()
|
closeSubcategoryConfigModal()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
adminError.value = error instanceof Error ? error.message : 'Unterkategorien konnten nicht gespeichert werden.'
|
adminError.value = error instanceof Error ? error.message : 'Unterkategorien konnten nicht gespeichert werden.'
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ function createEmptyForm(): AdminContentForm {
|
|||||||
return {
|
return {
|
||||||
hostDisplayName: '',
|
hostDisplayName: '',
|
||||||
hostTagline: '',
|
hostTagline: '',
|
||||||
|
hostArtistName: '',
|
||||||
|
hostImageUrl: '/assets/amaterasu2sei_2.png',
|
||||||
newsletterUrl: '',
|
newsletterUrl: '',
|
||||||
shareXUrl: '',
|
shareXUrl: '',
|
||||||
shareDiscordUrl: '',
|
shareDiscordUrl: '',
|
||||||
@@ -43,7 +45,7 @@ function createEmptyForm(): AdminContentForm {
|
|||||||
sponsorsContent: '',
|
sponsorsContent: '',
|
||||||
showactsUrl: '',
|
showactsUrl: '',
|
||||||
showactsContent: '',
|
showactsContent: '',
|
||||||
streamBannerEyebrow: 'Das grosse Finale',
|
streamBannerEyebrow: 'Das große Finale',
|
||||||
streamBannerTitle: 'Award-Show Finale',
|
streamBannerTitle: 'Award-Show Finale',
|
||||||
streamBannerText: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
|
streamBannerText: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
|
||||||
streamBannerLiveButtonLabel: 'Jetzt live · Zum Stream',
|
streamBannerLiveButtonLabel: 'Jetzt live · Zum Stream',
|
||||||
@@ -52,7 +54,7 @@ function createEmptyForm(): AdminContentForm {
|
|||||||
streamBannerUseCompletedContent: false,
|
streamBannerUseCompletedContent: false,
|
||||||
streamBannerCompletedEyebrow: 'Danke fürs Mitfiebern',
|
streamBannerCompletedEyebrow: 'Danke fürs Mitfiebern',
|
||||||
streamBannerCompletedTitle: 'Award-Show abgeschlossen',
|
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',
|
streamBannerCompletedButtonLabel: 'Highlights ansehen',
|
||||||
streamBannerCompletedButtonUrl: '',
|
streamBannerCompletedButtonUrl: '',
|
||||||
awardsSectionTitle: '',
|
awardsSectionTitle: '',
|
||||||
@@ -106,12 +108,16 @@ export function useAdminContentManager() {
|
|||||||
const saveError = ref('')
|
const saveError = ref('')
|
||||||
const privacyPreviewOpen = ref(false)
|
const privacyPreviewOpen = ref(false)
|
||||||
const iconUploadError = ref('')
|
const iconUploadError = ref('')
|
||||||
|
const hostImageUploadError = ref('')
|
||||||
|
const hostImageUploading = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.adminSiteSettings,
|
() => store.adminSiteSettings,
|
||||||
(settings) => {
|
(settings) => {
|
||||||
form.hostDisplayName = settings.hostDisplayName
|
form.hostDisplayName = settings.hostDisplayName
|
||||||
form.hostTagline = settings.hostTagline
|
form.hostTagline = settings.hostTagline
|
||||||
|
form.hostArtistName = settings.hostArtistName
|
||||||
|
form.hostImageUrl = settings.hostImageUrl
|
||||||
form.newsletterUrl = settings.newsletterUrl
|
form.newsletterUrl = settings.newsletterUrl
|
||||||
form.shareXUrl = settings.shareXUrl
|
form.shareXUrl = settings.shareXUrl
|
||||||
form.shareDiscordUrl = settings.shareDiscordUrl
|
form.shareDiscordUrl = settings.shareDiscordUrl
|
||||||
@@ -290,6 +296,40 @@ export function useAdminContentManager() {
|
|||||||
form.socialLinks[index].icon = ''
|
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') {
|
async function saveSiteSettings(sectionLabel = 'Landingpage-Inhalte') {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
saveMessage.value = ''
|
saveMessage.value = ''
|
||||||
@@ -299,6 +339,7 @@ export function useAdminContentManager() {
|
|||||||
await store.updateAdminSiteSettings({
|
await store.updateAdminSiteSettings({
|
||||||
hostDisplayName: form.hostDisplayName,
|
hostDisplayName: form.hostDisplayName,
|
||||||
hostTagline: form.hostTagline,
|
hostTagline: form.hostTagline,
|
||||||
|
hostArtistName: form.hostArtistName,
|
||||||
newsletterUrl: form.newsletterUrl,
|
newsletterUrl: form.newsletterUrl,
|
||||||
shareXUrl: form.shareXUrl,
|
shareXUrl: form.shareXUrl,
|
||||||
shareDiscordUrl: form.shareDiscordUrl,
|
shareDiscordUrl: form.shareDiscordUrl,
|
||||||
@@ -361,6 +402,8 @@ export function useAdminContentManager() {
|
|||||||
saveError,
|
saveError,
|
||||||
privacyPreviewOpen,
|
privacyPreviewOpen,
|
||||||
iconUploadError,
|
iconUploadError,
|
||||||
|
hostImageUploadError,
|
||||||
|
hostImageUploading,
|
||||||
privacyPreviewHtml,
|
privacyPreviewHtml,
|
||||||
privacyUpdatedLabel,
|
privacyUpdatedLabel,
|
||||||
addSocialLink,
|
addSocialLink,
|
||||||
@@ -376,6 +419,7 @@ export function useAdminContentManager() {
|
|||||||
socialSimpleIconColor,
|
socialSimpleIconColor,
|
||||||
handleSocialIconUpload,
|
handleSocialIconUpload,
|
||||||
clearSocialIcon,
|
clearSocialIcon,
|
||||||
|
handleHostImageUpload,
|
||||||
saveSiteSettings,
|
saveSiteSettings,
|
||||||
adminSiteSettings: computed(() => store.adminSiteSettings),
|
adminSiteSettings: computed(() => store.adminSiteSettings),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export function useAdminDashboardOverview() {
|
|||||||
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
|
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
|
||||||
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
|
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
|
||||||
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
|
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/clips')) return authStore.hasPermission('clips')
|
||||||
if (path.startsWith('/admin/users-logs')) return authStore.hasPermission('audit')
|
if (path.startsWith('/admin/users-logs')) return authStore.hasPermission('audit')
|
||||||
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
|
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
|
||||||
|
|||||||
@@ -139,9 +139,6 @@ export function useAdminReviewsManager(options?: {
|
|||||||
? (seasonDetail.value.trackingReviewNotes ?? '')
|
? (seasonDetail.value.trackingReviewNotes ?? '')
|
||||||
: '',
|
: '',
|
||||||
)
|
)
|
||||||
const selectedNominationHasBlockingFlag = computed(() =>
|
|
||||||
selectedNomination.value?.trackingFlags?.some((flag) => flag.blocksApproval) ?? false,
|
|
||||||
)
|
|
||||||
const selectedRelatedPendingNominations = computed<AdminNominationReviewItem[]>(() => {
|
const selectedRelatedPendingNominations = computed<AdminNominationReviewItem[]>(() => {
|
||||||
if (!selectedNomination.value) return []
|
if (!selectedNomination.value) return []
|
||||||
const nominationIds = new Set(selectedNomination.value.nominationIds)
|
const nominationIds = new Set(selectedNomination.value.nominationIds)
|
||||||
@@ -153,7 +150,6 @@ export function useAdminReviewsManager(options?: {
|
|||||||
if (!selectedNomination.value) return false
|
if (!selectedNomination.value) return false
|
||||||
const form = reviewForms[selectedNomination.value.id]
|
const form = reviewForms[selectedNomination.value.id]
|
||||||
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim() && form.categoryId)
|
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim() && form.categoryId)
|
||||||
&& !(selectedNominationHasBlockingFlag.value && selectedNomination.value.trackingReviewStatus !== 'overridden')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function focusNominationFromRoute() {
|
function focusNominationFromRoute() {
|
||||||
@@ -444,7 +440,6 @@ export function useAdminReviewsManager(options?: {
|
|||||||
selectedRelatedPendingNominations,
|
selectedRelatedPendingNominations,
|
||||||
selectedNominationSignalSummary,
|
selectedNominationSignalSummary,
|
||||||
trackingReviewNotes,
|
trackingReviewNotes,
|
||||||
selectedNominationHasBlockingFlag,
|
|
||||||
canApproveSelected,
|
canApproveSelected,
|
||||||
approveNomination,
|
approveNomination,
|
||||||
rejectNomination,
|
rejectNomination,
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function useAdminSeasonManager() {
|
|||||||
)
|
)
|
||||||
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
|
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
|
||||||
const latestSeasonAuditSummary = computed(() =>
|
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 latestSeasonAuditMeta = computed(() => {
|
||||||
const entry = latestSeasonAuditEntry.value
|
const entry = latestSeasonAuditEntry.value
|
||||||
@@ -530,7 +530,7 @@ function buildReadinessItems(
|
|||||||
? 'Alle Kategorien haben Kandidaten.'
|
? 'Alle Kategorien haben Kandidaten.'
|
||||||
: candidateBlocking
|
: candidateBlocking
|
||||||
? `${facts.emptyCategories} Kategorien brauchen noch Kandidaten.`
|
? `${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,
|
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && activeCandidateCount > 0,
|
||||||
blocking: candidateBlocking,
|
blocking: candidateBlocking,
|
||||||
to: '/admin/candidates',
|
to: '/admin/candidates',
|
||||||
@@ -559,7 +559,7 @@ function buildReadinessItems(
|
|||||||
? 'Alle Gewinner sind vergeben.'
|
? 'Alle Gewinner sind vergeben.'
|
||||||
: winnerBlocking
|
: winnerBlocking
|
||||||
? `${facts.missingResults} Kategorien brauchen vor Abschluss einen Gewinner.`
|
? `${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,
|
complete: detail.categories.length > 0 && facts.missingResults === 0,
|
||||||
blocking: winnerBlocking,
|
blocking: winnerBlocking,
|
||||||
to: '/admin/analytics',
|
to: '/admin/analytics',
|
||||||
@@ -606,7 +606,7 @@ function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (phaseKey !== 'nomination') {
|
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
|
return issues
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function normalizeFlagRule(rule: AdminTrackingFlagRule): AdminTrackingFlagRule {
|
|||||||
enabled: Boolean(rule.enabled),
|
enabled: Boolean(rule.enabled),
|
||||||
autoTriggerEnabled: Boolean(rule.autoTriggerEnabled),
|
autoTriggerEnabled: Boolean(rule.autoTriggerEnabled),
|
||||||
requiresManualReview: Boolean(rule.requiresManualReview),
|
requiresManualReview: Boolean(rule.requiresManualReview),
|
||||||
blocksApproval: Boolean(rule.blocksApproval),
|
blocksApproval: false,
|
||||||
adminNoteRequiredOnOverride: Boolean(rule.adminNoteRequiredOnOverride),
|
adminNoteRequiredOnOverride: Boolean(rule.adminNoteRequiredOnOverride),
|
||||||
severity: rule.severity === 'high' || rule.severity === 'low' ? rule.severity : 'medium',
|
severity: rule.severity === 'high' || rule.severity === 'low' ? rule.severity : 'medium',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function useAdminVotingManager() {
|
|||||||
const focusedCategoryId = ref<number | null>(null)
|
const focusedCategoryId = ref<number | null>(null)
|
||||||
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const seasonCandidates = computed(() => seasonDetail.value.candidates)
|
||||||
const votingWorkspace = computed(() => seasonDetail.value.votingWorkspace)
|
const votingWorkspace = computed(() => seasonDetail.value.votingWorkspace)
|
||||||
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
|
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export function useAdminVotingManager() {
|
|||||||
statusTone = 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
statusTone = 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||||
} else if (isProblem) {
|
} else if (isProblem) {
|
||||||
statusKey = 'issues'
|
statusKey = 'issues'
|
||||||
statusLabel = 'Pruefen'
|
statusLabel = 'Prüfen'
|
||||||
statusTone = 'border-amber-100 bg-amber-50 text-amber-700'
|
statusTone = 'border-amber-100 bg-amber-50 text-amber-700'
|
||||||
} else if (category.winnerReady) {
|
} else if (category.winnerReady) {
|
||||||
statusKey = 'ready'
|
statusKey = 'ready'
|
||||||
@@ -192,7 +193,7 @@ export function useAdminVotingManager() {
|
|||||||
{
|
{
|
||||||
label: 'Vorbereitung',
|
label: 'Vorbereitung',
|
||||||
value: String(votingWorkspace.value.summary.readySubcategories),
|
value: String(votingWorkspace.value.summary.readySubcategories),
|
||||||
note: 'Unterkategorien sind fuer Gewinner vorbereitet',
|
note: 'Unterkategorien sind für Gewinner vorbereitet',
|
||||||
icon: Trophy,
|
icon: Trophy,
|
||||||
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||||
},
|
},
|
||||||
@@ -213,7 +214,7 @@ export function useAdminVotingManager() {
|
|||||||
|
|
||||||
const statusFilters = computed(() => [
|
const statusFilters = computed(() => [
|
||||||
{ value: allFilter, label: 'Alle', count: workspaceRows.value.length },
|
{ 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: '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: '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 },
|
{ value: 'idle', label: 'Noch ohne Votes', count: workspaceRows.value.filter((row) => row.voteCount === 0).length },
|
||||||
@@ -281,6 +282,7 @@ export function useAdminVotingManager() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
statusFilters,
|
statusFilters,
|
||||||
summaryCards,
|
summaryCards,
|
||||||
|
seasonCandidates,
|
||||||
votingGroups,
|
votingGroups,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
selectedGroupName,
|
selectedGroupName,
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export function useAdminWinnersManager() {
|
|||||||
{
|
{
|
||||||
label: 'Reviews',
|
label: 'Reviews',
|
||||||
value: String(reviewWarningCount.value),
|
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,
|
icon: Clock3,
|
||||||
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||||
progress: null,
|
progress: null,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function initialsFor(value: string) {
|
|||||||
<component
|
<component
|
||||||
:is="winner.url ? 'a' : 'article'"
|
:is="winner.url ? 'a' : 'article'"
|
||||||
v-for="winner in props.selectedArchive.winners"
|
v-for="winner in props.selectedArchive.winners"
|
||||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
:key="`${props.selectedArchive.year}-${winner.category}-${winner.subcategory}-${winner.name}`"
|
||||||
:href="winner.url || undefined"
|
:href="winner.url || undefined"
|
||||||
:target="winner.url ? '_blank' : undefined"
|
:target="winner.url ? '_blank' : undefined"
|
||||||
:rel="winner.url ? 'noopener' : 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;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>
|
<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__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;">
|
<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 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 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 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;">
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
<a
|
<a
|
||||||
v-for="social in hostSocialLinks"
|
v-for="social in hostSocialLinks"
|
||||||
@@ -138,6 +142,8 @@ interface HomeSocialLink {
|
|||||||
interface HomeSiteContent {
|
interface HomeSiteContent {
|
||||||
hostDisplayName: string
|
hostDisplayName: string
|
||||||
hostTagline: string
|
hostTagline: string
|
||||||
|
hostArtistName: string
|
||||||
|
hostImageUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface HomeSocialLink {
|
|||||||
interface HomeSiteContent {
|
interface HomeSiteContent {
|
||||||
hostDisplayName: string
|
hostDisplayName: string
|
||||||
hostTagline: string
|
hostTagline: string
|
||||||
|
hostArtistName: string
|
||||||
|
hostImageUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const nominationCatValue = ref(0)
|
|||||||
const clipCatValue = ref(0)
|
const clipCatValue = ref(0)
|
||||||
const clipNomQuery = ref('')
|
const clipNomQuery = ref('')
|
||||||
const nominationDrafts = ref<Record<number, string[]>>({})
|
const nominationDrafts = ref<Record<number, string[]>>({})
|
||||||
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
|
|
||||||
const nominationReviewMode = ref(false)
|
const nominationReviewMode = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -99,39 +98,25 @@ function notifyClipCategoryChange(value: number) {
|
|||||||
|
|
||||||
function syncNominationDrafts(options: HomeSelectionOption[]) {
|
function syncNominationDrafts(options: HomeSelectionOption[]) {
|
||||||
const nextDrafts: Record<number, string[]> = {}
|
const nextDrafts: Record<number, string[]> = {}
|
||||||
const nextVisibleLinkCounts: Record<number, number> = {}
|
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
const existing = nominationDrafts.value[option.id] ?? []
|
const existing = nominationDrafts.value[option.id] ?? []
|
||||||
const limit = nominationLinkLimitFor(option.id)
|
const limit = nominationLinkLimitFor(option.id)
|
||||||
nextDrafts[option.id] = createNominationLinkSlots(existing, limit)
|
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
|
nominationDrafts.value = nextDrafts
|
||||||
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nominationLinksFor(categoryIndex: number) {
|
function nominationLinksFor(categoryIndex: number) {
|
||||||
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
|
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
function nominationVisibleLinkCountFor(categoryIndex: number) {
|
|
||||||
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactNominationCategory(categoryIndex: number) {
|
function compactNominationCategory(categoryIndex: number) {
|
||||||
const limit = nominationLinkLimitFor(categoryIndex)
|
const limit = nominationLinkLimitFor(categoryIndex)
|
||||||
const links = nominationLinksFor(categoryIndex)
|
const links = nominationLinksFor(categoryIndex)
|
||||||
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, limit)
|
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 = {
|
||||||
...nominationDrafts.value,
|
...nominationDrafts.value,
|
||||||
[categoryIndex]: nextLinks,
|
[categoryIndex]: createNominationLinkSlots(compactedLinks, limit),
|
||||||
}
|
|
||||||
nominationVisibleLinkCounts.value = {
|
|
||||||
...nominationVisibleLinkCounts.value,
|
|
||||||
[categoryIndex]: nextVisibleCount,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,9 +148,6 @@ const activeNominationCategory = computed(() =>
|
|||||||
)
|
)
|
||||||
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
|
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
|
||||||
const activeNominationErrors = computed(() => nominationFieldErrors(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(() =>
|
const nominationReviewItems = computed(() =>
|
||||||
props.catOptions
|
props.catOptions
|
||||||
.map((option) => ({
|
.map((option) => ({
|
||||||
@@ -239,36 +221,6 @@ function editNominationCategory(categoryIndex: number) {
|
|||||||
nominationReviewMode.value = false
|
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() {
|
async function submitNominationDrafts() {
|
||||||
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
|
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
|
||||||
await props.submitNomination({
|
await props.submitNomination({
|
||||||
@@ -371,11 +323,7 @@ function stripCategoryIcon(value: string) {
|
|||||||
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||||
:links="activeNominationLinks"
|
:links="activeNominationLinks"
|
||||||
:errors="activeNominationErrors"
|
:errors="activeNominationErrors"
|
||||||
:visible-link-count="activeNominationVisibleLinkCount"
|
|
||||||
:remaining-link-count="activeNominationRemainingLinks"
|
|
||||||
:on-link-input="onNominationLinkInput"
|
:on-link-input="onNominationLinkInput"
|
||||||
:on-add-link-field="addNominationLinkField"
|
|
||||||
:on-remove-link-field="removeNominationLinkField"
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ const props = defineProps<{
|
|||||||
categoryName: string
|
categoryName: string
|
||||||
links: string[]
|
links: string[]
|
||||||
errors: string[]
|
errors: string[]
|
||||||
visibleLinkCount: number
|
|
||||||
remainingLinkCount: number
|
|
||||||
onLinkInput: (index: number, value: string) => void
|
onLinkInput: (index: number, value: string) => void
|
||||||
onAddLinkField: () => void
|
|
||||||
onRemoveLinkField: (index: number) => void
|
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -20,17 +16,9 @@ const props = defineProps<{
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="home-nomination-pane__fields">
|
<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 class="home-nomination-field__header">
|
||||||
<span>{{ index === 0 ? 'Link 1' : `Link ${index + 1} optional` }}</span>
|
<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>
|
</span>
|
||||||
<input
|
<input
|
||||||
:value="props.links[index]"
|
:value="props.links[index]"
|
||||||
@@ -44,16 +32,5 @@ const props = defineProps<{
|
|||||||
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
|
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.va
|
|||||||
const safeNewsletterUrl = computed(() =>
|
const safeNewsletterUrl = computed(() =>
|
||||||
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
|
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 isSponsorFooterModal = computed(() => activeFooterLink.value?.key === 'sponsors')
|
||||||
const footerModalEyebrow = computed(() =>
|
const footerModalEyebrow = computed(() =>
|
||||||
isSponsorFooterModal.value ? 'Support the show' : 'Footer Seite',
|
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>
|
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
|
||||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
|
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
|
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
|
||||||
<a 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="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="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="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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -703,47 +703,6 @@ html,body{margin:0;padding:0;background:#160a26;}
|
|||||||
font-weight:800;
|
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-nomination-review__hero h4,
|
||||||
.home-missing-votes h4{
|
.home-missing-votes h4{
|
||||||
margin:0 0 8px;
|
margin:0 0 8px;
|
||||||
@@ -2249,11 +2208,6 @@ html,body{margin:0;padding:0;background:#160a26;}
|
|||||||
min-height:0!important;
|
min-height:0!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-nomination-pane__add-link{
|
|
||||||
align-items:flex-start!important;
|
|
||||||
flex-direction:column!important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-nomination-review__stats{
|
.home-nomination-review__stats{
|
||||||
grid-template-columns:1fr!important;
|
grid-template-columns:1fr!important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAuditEntriesResponse,
|
AdminAuditEntriesResponse,
|
||||||
|
AdminArchivedWinnerItem,
|
||||||
AdminAuditQueryOptions,
|
AdminAuditQueryOptions,
|
||||||
|
AdminCandidateDeletePreview,
|
||||||
AdminDashboardResponse,
|
AdminDashboardResponse,
|
||||||
AdminOptionalFeatureSettingsResponse,
|
AdminOptionalFeatureSettingsResponse,
|
||||||
AdminOperationalSettingsResponse,
|
AdminOperationalSettingsResponse,
|
||||||
@@ -43,6 +45,7 @@ import type {
|
|||||||
UpdateTeamRolesPayload,
|
UpdateTeamRolesPayload,
|
||||||
UpdateWorkflowRulesPayload,
|
UpdateWorkflowRulesPayload,
|
||||||
UpsertCandidatePayload,
|
UpsertCandidatePayload,
|
||||||
|
UpsertArchivedWinnerPayload,
|
||||||
UpsertCategoryPayload,
|
UpsertCategoryPayload,
|
||||||
UpsertCategoryGroupPayload,
|
UpsertCategoryGroupPayload,
|
||||||
UpsertSponsorPayload,
|
UpsertSponsorPayload,
|
||||||
@@ -139,7 +142,25 @@ export const adminApi = {
|
|||||||
requestJson<{ saved: boolean; sponsor: AdminSponsorItem }>(`/api/admin/sponsors/${sponsorId}`, jsonRequest('PUT', payload)),
|
requestJson<{ saved: boolean; sponsor: AdminSponsorItem }>(`/api/admin/sponsors/${sponsorId}`, jsonRequest('PUT', payload)),
|
||||||
deleteAdminSponsor: (sponsorId: number) =>
|
deleteAdminSponsor: (sponsorId: number) =>
|
||||||
requestJson<{ deleted: boolean; sponsorId: number }>(`/api/admin/sponsors/${sponsorId}`, { method: 'DELETE' }),
|
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'),
|
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: () =>
|
getAdminOptionalFeatureSettings: () =>
|
||||||
requestJson<AdminOptionalFeatureSettingsResponse>('/api/admin/optional-feature-settings'),
|
requestJson<AdminOptionalFeatureSettingsResponse>('/api/admin/optional-feature-settings'),
|
||||||
updateAdminOptionalFeatureSettings: (payload: UpdateOptionalFeatureSettingsPayload) =>
|
updateAdminOptionalFeatureSettings: (payload: UpdateOptionalFeatureSettingsPayload) =>
|
||||||
@@ -204,6 +225,8 @@ export const adminApi = {
|
|||||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
|
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
|
||||||
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
|
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
|
||||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, jsonRequest('PUT', payload)),
|
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) =>
|
deleteAdminCandidate: (candidateId: number) =>
|
||||||
requestJson<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, {
|
requestJson<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../views/admin/AdminCandidatesView.vue'),
|
component: () => import('../views/admin/AdminCandidatesView.vue'),
|
||||||
meta: { keepAlive: true },
|
meta: { keepAlive: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'archive',
|
||||||
|
name: 'admin-archive',
|
||||||
|
component: () => import('../views/admin/AdminArchiveView.vue'),
|
||||||
|
meta: { keepAlive: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'clips',
|
path: 'clips',
|
||||||
name: 'admin-clips',
|
name: 'admin-clips',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ApproveNominationPayload,
|
ApproveNominationPayload,
|
||||||
AddNominationLinkBlacklistEntryPayload,
|
AddNominationLinkBlacklistEntryPayload,
|
||||||
|
UpsertArchivedWinnerPayload,
|
||||||
CreateSeasonPayload,
|
CreateSeasonPayload,
|
||||||
CreateClipPayload,
|
CreateClipPayload,
|
||||||
CreateNominationPayload,
|
CreateNominationPayload,
|
||||||
@@ -150,6 +151,7 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
|
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
|
||||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||||
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
|
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
|
||||||
|
this.adminArchivedWinners = []
|
||||||
this.adminRiskHistory = []
|
this.adminRiskHistory = []
|
||||||
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
|
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
|
||||||
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
|
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
|
||||||
@@ -215,6 +217,7 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
this.adminSponsors = []
|
this.adminSponsors = []
|
||||||
this.adminShowactApplications = []
|
this.adminShowactApplications = []
|
||||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||||
|
this.adminArchivedWinners = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async initializeAdminWorkspace() {
|
async initializeAdminWorkspace() {
|
||||||
@@ -316,6 +319,25 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
|
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
|
||||||
return result
|
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) {
|
async updateAdminSponsor(sponsorId: number, seasonId: number, payload: UpsertSponsorPayload) {
|
||||||
const result = await api.updateAdminSponsor(sponsorId, payload)
|
const result = await api.updateAdminSponsor(sponsorId, payload)
|
||||||
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
|
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
|
||||||
@@ -561,6 +583,11 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
await this.loadHomeData()
|
await this.loadHomeData()
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
async uploadAdminHostImage(file: File) {
|
||||||
|
this.adminSiteSettings = await api.uploadAdminHostImage(file)
|
||||||
|
await this.loadHomeData()
|
||||||
|
return this.adminSiteSettings
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiRequestError } from '../../lib/http'
|
import { ApiRequestError } from '../../lib/http'
|
||||||
import type {
|
import type {
|
||||||
AdminDashboardResponse,
|
AdminDashboardResponse,
|
||||||
|
AdminArchivedWinnerItem,
|
||||||
AdminOptionalFeatureSettingsResponse,
|
AdminOptionalFeatureSettingsResponse,
|
||||||
AdminOperationalSettingsResponse,
|
AdminOperationalSettingsResponse,
|
||||||
AdminRiskFlag,
|
AdminRiskFlag,
|
||||||
@@ -39,6 +40,8 @@ export function createEmptyOverview(): OverviewResponse {
|
|||||||
siteContent: {
|
siteContent: {
|
||||||
hostDisplayName: '',
|
hostDisplayName: '',
|
||||||
hostTagline: '',
|
hostTagline: '',
|
||||||
|
hostArtistName: '',
|
||||||
|
hostImageUrl: '/assets/amaterasu2sei_2.png',
|
||||||
newsletterUrl: '',
|
newsletterUrl: '',
|
||||||
shareXUrl: '',
|
shareXUrl: '',
|
||||||
shareDiscordUrl: '',
|
shareDiscordUrl: '',
|
||||||
@@ -220,6 +223,8 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
|
|||||||
return {
|
return {
|
||||||
hostDisplayName: '',
|
hostDisplayName: '',
|
||||||
hostTagline: '',
|
hostTagline: '',
|
||||||
|
hostArtistName: '',
|
||||||
|
hostImageUrl: '/assets/amaterasu2sei_2.png',
|
||||||
newsletterUrl: '',
|
newsletterUrl: '',
|
||||||
shareXUrl: '',
|
shareXUrl: '',
|
||||||
shareDiscordUrl: '',
|
shareDiscordUrl: '',
|
||||||
@@ -297,6 +302,7 @@ export function createAwardsState() {
|
|||||||
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
|
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
|
||||||
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
|
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
|
||||||
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
|
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
|
||||||
|
adminArchivedWinners: [] as AdminArchivedWinnerItem[],
|
||||||
adminSponsors: [] as AdminSponsorItem[],
|
adminSponsors: [] as AdminSponsorItem[],
|
||||||
adminShowactApplications: [] as AdminShowactApplicationItem[],
|
adminShowactApplications: [] as AdminShowactApplicationItem[],
|
||||||
adminRiskHistory: [] as AdminRiskFlag[],
|
adminRiskHistory: [] as AdminRiskFlag[],
|
||||||
@@ -331,6 +337,8 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
|
|||||||
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
|
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
|
||||||
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
|
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
|
||||||
streamerIdentityId: candidate.streamerIdentityId ?? null,
|
streamerIdentityId: candidate.streamerIdentityId ?? null,
|
||||||
|
avgViewers: candidate.avgViewers ?? null,
|
||||||
|
votes: candidate.votes ?? 0,
|
||||||
nominationTally: candidate.nominationTally ?? 0,
|
nominationTally: candidate.nominationTally ?? 0,
|
||||||
})),
|
})),
|
||||||
pendingNominations: detail.pendingNominations ?? [],
|
pendingNominations: detail.pendingNominations ?? [],
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export interface AdminCandidateItem {
|
|||||||
displayName: string
|
displayName: string
|
||||||
channelSlug: string
|
channelSlug: string
|
||||||
platform: string
|
platform: string
|
||||||
|
avgViewers: number | null
|
||||||
|
votes: number
|
||||||
nominationTally: number
|
nominationTally: number
|
||||||
acceptanceStatus: string
|
acceptanceStatus: string
|
||||||
acceptanceNote: string | null
|
acceptanceNote: string | null
|
||||||
@@ -273,6 +275,14 @@ export interface AdminCandidateItem {
|
|||||||
clipEmbedStatus: string
|
clipEmbedStatus: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminCandidateDeletePreview {
|
||||||
|
candidateId: number
|
||||||
|
nominationCount: number
|
||||||
|
clipCount: number
|
||||||
|
voteCount: number
|
||||||
|
resultCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminAwardResultItem {
|
export interface AdminAwardResultItem {
|
||||||
id: number
|
id: number
|
||||||
categoryId: number
|
categoryId: number
|
||||||
@@ -453,6 +463,8 @@ export interface AdminSeasonDetailResponse {
|
|||||||
export interface AdminSiteSettingsResponse {
|
export interface AdminSiteSettingsResponse {
|
||||||
hostDisplayName: string
|
hostDisplayName: string
|
||||||
hostTagline: string
|
hostTagline: string
|
||||||
|
hostArtistName: string
|
||||||
|
hostImageUrl: string
|
||||||
newsletterUrl: string
|
newsletterUrl: string
|
||||||
shareXUrl: string
|
shareXUrl: string
|
||||||
shareDiscordUrl: string
|
shareDiscordUrl: string
|
||||||
@@ -533,6 +545,17 @@ export interface AdminSponsorItem {
|
|||||||
isVisible: boolean
|
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 {
|
export interface AdminShowactApplicationItem {
|
||||||
id: number
|
id: number
|
||||||
seasonId: number
|
seasonId: number
|
||||||
|
|||||||
@@ -244,6 +244,14 @@ export interface SetAwardResultPayload {
|
|||||||
candidateId: number
|
candidateId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpsertArchivedWinnerPayload {
|
||||||
|
year: number
|
||||||
|
category: string
|
||||||
|
subcategory: string
|
||||||
|
winnerName: string
|
||||||
|
winnerUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApproveNominationPayload {
|
export interface ApproveNominationPayload {
|
||||||
displayName: string
|
displayName: string
|
||||||
channelSlug: string
|
channelSlug: string
|
||||||
@@ -276,6 +284,7 @@ export interface AddNominationLinkBlacklistEntryPayload {
|
|||||||
export interface UpdateSiteSettingsPayload {
|
export interface UpdateSiteSettingsPayload {
|
||||||
hostDisplayName: string
|
hostDisplayName: string
|
||||||
hostTagline: string
|
hostTagline: string
|
||||||
|
hostArtistName: string
|
||||||
newsletterUrl: string
|
newsletterUrl: string
|
||||||
shareXUrl: string
|
shareXUrl: string
|
||||||
shareDiscordUrl: string
|
shareDiscordUrl: string
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface PublicStreamBannerContent {
|
|||||||
export interface PublicSiteContent {
|
export interface PublicSiteContent {
|
||||||
hostDisplayName: string
|
hostDisplayName: string
|
||||||
hostTagline: string
|
hostTagline: string
|
||||||
|
hostArtistName: string
|
||||||
|
hostImageUrl: string
|
||||||
newsletterUrl: string
|
newsletterUrl: string
|
||||||
shareXUrl: string
|
shareXUrl: string
|
||||||
shareDiscordUrl: 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,
|
categoryFilterOptions,
|
||||||
categoryLabelMap,
|
categoryLabelMap,
|
||||||
duplicateCandidateKeys,
|
duplicateCandidateKeys,
|
||||||
candidateIdentitySummaries,
|
|
||||||
candidateWorkflowNotices,
|
candidateWorkflowNotices,
|
||||||
duplicateCandidateCount,
|
duplicateCandidateCount,
|
||||||
acceptedCandidateCount,
|
acceptedCandidateCount,
|
||||||
@@ -48,9 +47,13 @@ const {
|
|||||||
clipEmbedStatusOptions,
|
clipEmbedStatusOptions,
|
||||||
readinessFilterOptions,
|
readinessFilterOptions,
|
||||||
candidateToDelete,
|
candidateToDelete,
|
||||||
|
deletePreviewLoading,
|
||||||
|
candidateDeletePreview,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
|
openDelete,
|
||||||
|
closeDeleteModal,
|
||||||
handlePlatformSelection,
|
handlePlatformSelection,
|
||||||
saveModal,
|
saveModal,
|
||||||
confirmDelete,
|
confirmDelete,
|
||||||
@@ -149,11 +152,10 @@ watchAdminToast(adminMessage, adminError)
|
|||||||
:range-end="rangeEnd"
|
:range-end="rangeEnd"
|
||||||
:category-label-map="categoryLabelMap"
|
:category-label-map="categoryLabelMap"
|
||||||
:duplicate-candidate-keys="duplicateCandidateKeys"
|
:duplicate-candidate-keys="duplicateCandidateKeys"
|
||||||
:candidate-identity-summaries="candidateIdentitySummaries"
|
|
||||||
:candidate-workflow-notices="candidateWorkflowNotices"
|
:candidate-workflow-notices="candidateWorkflowNotices"
|
||||||
:acceptance-status-options="acceptanceStatusOptions"
|
:acceptance-status-options="acceptanceStatusOptions"
|
||||||
@edit="openEdit"
|
@edit="openEdit"
|
||||||
@delete="candidateToDelete = $event"
|
@delete="openDelete"
|
||||||
@update:page="page = $event"
|
@update:page="page = $event"
|
||||||
@open-create="openCreate"
|
@open-create="openCreate"
|
||||||
/>
|
/>
|
||||||
@@ -189,8 +191,10 @@ watchAdminToast(adminMessage, adminError)
|
|||||||
|
|
||||||
<AdminCandidateDeleteModal
|
<AdminCandidateDeleteModal
|
||||||
:candidate="candidateToDelete"
|
:candidate="candidateToDelete"
|
||||||
|
:preview="candidateDeletePreview"
|
||||||
|
:loading="deletePreviewLoading"
|
||||||
:deleting="deleting"
|
:deleting="deleting"
|
||||||
@close="candidateToDelete = null"
|
@close="closeDeleteModal"
|
||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const {
|
|||||||
saveError,
|
saveError,
|
||||||
privacyPreviewOpen,
|
privacyPreviewOpen,
|
||||||
iconUploadError,
|
iconUploadError,
|
||||||
|
hostImageUploadError,
|
||||||
|
hostImageUploading,
|
||||||
privacyPreviewHtml,
|
privacyPreviewHtml,
|
||||||
privacyUpdatedLabel,
|
privacyUpdatedLabel,
|
||||||
addSocialLink,
|
addSocialLink,
|
||||||
@@ -47,6 +49,7 @@ const {
|
|||||||
socialSimpleIconColor,
|
socialSimpleIconColor,
|
||||||
handleSocialIconUpload,
|
handleSocialIconUpload,
|
||||||
clearSocialIcon,
|
clearSocialIcon,
|
||||||
|
handleHostImageUpload,
|
||||||
saveSiteSettings,
|
saveSiteSettings,
|
||||||
adminSiteSettings,
|
adminSiteSettings,
|
||||||
} = useAdminContentManager()
|
} = useAdminContentManager()
|
||||||
@@ -164,7 +167,14 @@ function closeContentEditor() {
|
|||||||
:icon="FileText"
|
: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">
|
<section aria-labelledby="content-editor-title" class="space-y-4">
|
||||||
<div>
|
<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: '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: '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: '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 ? [{
|
...(clipAdminMenuVisible.value ? [{
|
||||||
label: 'Clips',
|
label: 'Clips',
|
||||||
to: '/admin/clips',
|
to: '/admin/clips',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
import AdminTeamRolePermissionsModal from '../../components/admin/AdminTeamRolePermissionsModal.vue'
|
|
||||||
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
||||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||||
@@ -22,7 +21,6 @@ const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authSt
|
|||||||
const passwordModalOpen = ref(false)
|
const passwordModalOpen = ref(false)
|
||||||
const createMemberModalOpen = ref(false)
|
const createMemberModalOpen = ref(false)
|
||||||
const editMemberModalOpen = ref(false)
|
const editMemberModalOpen = ref(false)
|
||||||
const rolePermissionsModalOpen = ref(false)
|
|
||||||
const profileError = ref('')
|
const profileError = ref('')
|
||||||
const profileSuccess = ref('')
|
const profileSuccess = ref('')
|
||||||
const ownPasswordForm = reactive({
|
const ownPasswordForm = reactive({
|
||||||
@@ -132,6 +130,10 @@ function canDeleteMember(member: AdminTeamMember) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
|
||||||
|
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateMemberModal() {
|
function openCreateMemberModal() {
|
||||||
startCreateMember()
|
startCreateMember()
|
||||||
createMemberModalOpen.value = true
|
createMemberModalOpen.value = true
|
||||||
@@ -281,16 +283,6 @@ async function changeOwnPassword() {
|
|||||||
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
|
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</Button>
|
</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">
|
<Button size="sm" type="button" @click="openCreateMemberModal">
|
||||||
<UserPlus class="mr-2 h-4 w-4" />
|
<UserPlus class="mr-2 h-4 w-4" />
|
||||||
Neuer Login
|
Neuer Login
|
||||||
@@ -377,18 +369,84 @@ async function changeOwnPassword() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AdminTeamRolePermissionsModal
|
<Card class="p-4 md:p-5">
|
||||||
:open="rolePermissionsModalOpen"
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
:saving="saving"
|
<div class="flex items-start gap-3">
|
||||||
:has-role-changes="hasRoleChanges"
|
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
|
||||||
:roles="roles"
|
<ShieldCheck class="h-4 w-4" />
|
||||||
:permissions="permissions"
|
</span>
|
||||||
:permission-groups="permissionGroups"
|
<div>
|
||||||
:role-has-permission="roleHasPermission"
|
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
|
||||||
:set-role-permission="setRolePermission"
|
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
|
||||||
:save-role-permissions="saveRolePermissions"
|
<p class="mt-1 text-xs font-semibold text-slate-500">
|
||||||
@close="rolePermissionsModalOpen = false"
|
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">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function windowSupportText(rule: AdminTrackingMetricRule) {
|
|||||||
.map((value) => windowOptions.find((item) => item.value === value)?.label ?? value)
|
.map((value) => windowOptions.find((item) => item.value === value)?.label ?? value)
|
||||||
.join(', ')
|
.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') {
|
function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'optional') {
|
||||||
@@ -93,9 +93,9 @@ function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'op
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rule.sourceSupport === 'auto') {
|
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') {
|
} 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 {
|
} else {
|
||||||
parts.push('Diese Metrik dient nur als Review-Kontext.')
|
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>
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button class="gap-2" :disabled="trackingLoading || sourceSaving || !hasUnsavedTrackingSourceChanges || !canManageTrackingRules" @click="saveTrackingSource">
|
<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 class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Manual Review Notes</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
|
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
|
||||||
@@ -247,7 +247,7 @@ function activateOptionalMetric(ruleKey: string) {
|
|||||||
rows="10"
|
rows="10"
|
||||||
:disabled="!canManageTrackingRules"
|
: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"
|
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 - manueller Check fuer Category Fit - Sonderregeln fuer kleine Kanaele"
|
placeholder="- SullyGnome Channel Summary - manueller Check für Category Fit - Sonderregeln für kleine Kanäle"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -296,7 +296,7 @@ function activateOptionalMetric(ruleKey: string) {
|
|||||||
{{ sourceSupportLabel(rule.sourceSupport) }}
|
{{ sourceSupportLabel(rule.sourceSupport) }}
|
||||||
</span>
|
</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">
|
<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>
|
||||||
<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">
|
<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
|
Im Review sichtbar
|
||||||
@@ -328,7 +328,7 @@ function activateOptionalMetric(ruleKey: string) {
|
|||||||
</label>
|
</label>
|
||||||
<AdminSettingsToggle
|
<AdminSettingsToggle
|
||||||
:model-value="rule.requiredForAutoClassification"
|
:model-value="rule.requiredForAutoClassification"
|
||||||
:label="`${rule.label} fuer Auto-Check erzwingen`"
|
:label="`${rule.label} für Auto-Check erzwingen`"
|
||||||
:disabled="!canManageTrackingRules"
|
:disabled="!canManageTrackingRules"
|
||||||
active-label="Pflicht"
|
active-label="Pflicht"
|
||||||
inactive-label="Nicht Pflicht"
|
inactive-label="Nicht Pflicht"
|
||||||
@@ -373,7 +373,7 @@ function activateOptionalMetric(ruleKey: string) {
|
|||||||
<Card class="overflow-visible">
|
<Card class="overflow-visible">
|
||||||
<section class="border-b border-violet-100 p-5">
|
<section class="border-b border-violet-100 p-5">
|
||||||
<h2 class="text-xl font-bold text-slate-900">Optionale Metriken</h2>
|
<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>
|
||||||
|
|
||||||
<section class="space-y-4 p-5">
|
<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 v-if="isTopCategoriesRule(rule)" class="mt-4 space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext fuer Top Categories</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 fuer Admins.</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>
|
||||||
|
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<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">
|
<Card class="overflow-visible">
|
||||||
<section class="border-b border-violet-100 p-5">
|
<section class="border-b border-violet-100 p-5">
|
||||||
<h2 class="text-xl font-bold text-slate-900">Flags</h2>
|
<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>
|
||||||
<section class="grid gap-3 p-5">
|
<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">
|
<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"
|
:model-value="rule.requiresManualReview"
|
||||||
:label="`${rule.label} braucht Review`"
|
:label="`${rule.label} braucht Review`"
|
||||||
:disabled="!canManageTrackingRules"
|
:disabled="!canManageTrackingRules"
|
||||||
active-label="Review noetig"
|
active-label="Review nötig"
|
||||||
inactive-label="Nur Hinweis"
|
inactive-label="Nur Hinweis"
|
||||||
@update:model-value="updateTrackingFlag(rule.key, { requiresManualReview: $event })"
|
@update:model-value="updateTrackingFlag(rule.key, { requiresManualReview: $event })"
|
||||||
/>
|
/>
|
||||||
<AdminSettingsToggle
|
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||||
:model-value="rule.blocksApproval"
|
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Approve-Verhalten</p>
|
||||||
:label="`${rule.label} blockiert Approve`"
|
<p class="mt-2 text-sm font-semibold text-slate-900">Warnt nur</p>
|
||||||
:disabled="!canManageTrackingRules"
|
<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>
|
||||||
active-label="Blockiert"
|
</div>
|
||||||
inactive-label="Warnt nur"
|
|
||||||
@update:model-value="updateTrackingFlag(rule.key, { blocksApproval: $event })"
|
|
||||||
/>
|
|
||||||
<AdminSettingsToggle
|
<AdminSettingsToggle
|
||||||
:model-value="rule.adminNoteRequiredOnOverride"
|
:model-value="rule.adminNoteRequiredOnOverride"
|
||||||
:label="`${rule.label} braucht Override-Notiz`"
|
:label="`${rule.label} braucht Override-Notiz`"
|
||||||
:disabled="!canManageTrackingRules"
|
:disabled="!canManageTrackingRules"
|
||||||
active-label="Notiz noetig"
|
active-label="Notiz nötig"
|
||||||
inactive-label="Optional"
|
inactive-label="Optional"
|
||||||
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
|
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
statusFilters,
|
statusFilters,
|
||||||
summaryCards,
|
summaryCards,
|
||||||
|
seasonCandidates,
|
||||||
votingGroups,
|
votingGroups,
|
||||||
selectedGroupName,
|
selectedGroupName,
|
||||||
focusedCategoryId,
|
focusedCategoryId,
|
||||||
@@ -117,6 +118,7 @@ function openLeaderboard(category: AdminVotingWorkspaceRow) {
|
|||||||
|
|
||||||
<AdminVotingLeaderboardModal
|
<AdminVotingLeaderboardModal
|
||||||
:category="leaderboardCategory"
|
:category="leaderboardCategory"
|
||||||
|
:candidates="seasonCandidates"
|
||||||
@close="leaderboardCategory = null"
|
@close="leaderboardCategory = null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ watchAdminToast(adminMessage, adminError)
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
eyebrow="Gewinner"
|
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"
|
:icon="Trophy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user