Compare commits
20 Commits
670259a983
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2d6a5edc0 | |||
| 0fa763667d | |||
| 178f014a4a | |||
| 1e101ee2fb | |||
| 65ac0861da | |||
| adaea5cbce | |||
| f85a0d7c82 | |||
| 37f7cad5dc | |||
| 9473ff214a | |||
| 78bf9fd503 | |||
| f3696154b2 | |||
| 6b679937fe | |||
| 12cf63ef49 | |||
| 567f0e2ebf | |||
| 33975a633c | |||
| 6d08d6ea01 | |||
| e2c74a7378 | |||
| 4a211189f0 | |||
| 953257bcef | |||
| 92dd6f7432 |
@@ -6,10 +6,33 @@ public sealed record AdminActivityDto(string Label, string Age);
|
|||||||
|
|
||||||
public sealed record AdminTopCategoryDto(string Category, int Votes);
|
public sealed record AdminTopCategoryDto(string Category, int Votes);
|
||||||
|
|
||||||
|
public sealed record AdminRiskFlagDto(
|
||||||
|
int Id,
|
||||||
|
string Source,
|
||||||
|
string Type,
|
||||||
|
string Severity,
|
||||||
|
string Status,
|
||||||
|
string Summary,
|
||||||
|
string? TwitchUserId,
|
||||||
|
string CreatedFromIp,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string MetadataJson);
|
||||||
|
|
||||||
|
public sealed record AdminAuditEntryDto(
|
||||||
|
int Id,
|
||||||
|
string AdminTwitchUserId,
|
||||||
|
string ActionType,
|
||||||
|
string EntityType,
|
||||||
|
string EntityId,
|
||||||
|
string Summary,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
public sealed record AdminDashboardResponse(
|
public sealed record AdminDashboardResponse(
|
||||||
IEnumerable<AdminMetricDto> Metrics,
|
IEnumerable<AdminMetricDto> Metrics,
|
||||||
IEnumerable<AdminActivityDto> Activities,
|
IEnumerable<AdminActivityDto> Activities,
|
||||||
IEnumerable<AdminTopCategoryDto> TopCategories);
|
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||||
|
IEnumerable<AdminRiskFlagDto> RiskFlags,
|
||||||
|
IEnumerable<AdminAuditEntryDto> AuditEntries);
|
||||||
|
|
||||||
public sealed record AdminSeasonListItemDto(
|
public sealed record AdminSeasonListItemDto(
|
||||||
int Id,
|
int Id,
|
||||||
@@ -76,3 +99,5 @@ public sealed record ApproveNominationRequest(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? ChannelSlug,
|
string? ChannelSlug,
|
||||||
string? Platform);
|
string? Platform);
|
||||||
|
|
||||||
|
public sealed record ResolveRiskFlagRequest(string Status);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||||
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
||||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||||
|
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||||
|
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -62,6 +64,30 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
||||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||||
entity.Property(item => item.Role).HasMaxLength(40);
|
entity.Property(item => item.Role).HasMaxLength(40);
|
||||||
|
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RiskFlag>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.Source).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.Type).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.Severity).HasMaxLength(20);
|
||||||
|
entity.Property(item => item.Status).HasMaxLength(20);
|
||||||
|
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||||
|
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||||
|
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<AdminAuditEntry>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(item => item.AdminTwitchUserId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.ActionType).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.EntityType).HasMaxLength(80);
|
||||||
|
entity.Property(item => item.EntityId).HasMaxLength(120);
|
||||||
|
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||||
});
|
});
|
||||||
|
|
||||||
SeedData.Apply(modelBuilder);
|
SeedData.Apply(modelBuilder);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Data;
|
||||||
|
|
||||||
|
public static class OperationalTablesBootstrapper
|
||||||
|
{
|
||||||
|
public static Task EnsureAsync(AwardsDbContext db) =>
|
||||||
|
db.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
ALTER TABLE "UserSessions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE "UserSessions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||||
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"SeasonId" integer NULL,
|
||||||
|
"TwitchUserId" character varying(120) NULL,
|
||||||
|
"Source" character varying(80) NOT NULL,
|
||||||
|
"Type" character varying(80) NOT NULL,
|
||||||
|
"Severity" character varying(20) NOT NULL,
|
||||||
|
"Status" character varying(20) NOT NULL,
|
||||||
|
"Summary" character varying(240) NOT NULL,
|
||||||
|
"CreatedFromIp" character varying(80) NOT NULL,
|
||||||
|
"UserAgent" character varying(400) NOT NULL,
|
||||||
|
"MetadataJson" text NOT NULL,
|
||||||
|
"ReviewedByTwitchId" character varying(120) NULL,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL,
|
||||||
|
"ReviewedAt" timestamp with time zone NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
|
||||||
|
ON "RiskFlags" ("Status", "CreatedAt" DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_SeasonId"
|
||||||
|
ON "RiskFlags" ("SeasonId");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
|
||||||
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"AdminTwitchUserId" character varying(120) NOT NULL,
|
||||||
|
"ActionType" character varying(80) NOT NULL,
|
||||||
|
"EntityType" character varying(80) NOT NULL,
|
||||||
|
"EntityId" character varying(120) NOT NULL,
|
||||||
|
"Summary" character varying(240) NOT NULL,
|
||||||
|
"MetadataJson" text NOT NULL,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
|
||||||
|
ON "AdminAuditEntries" ("CreatedAt" DESC);
|
||||||
|
""");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class AdminAuditEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string AdminTwitchUserId { get; set; } = string.Empty;
|
||||||
|
public string ActionType { get; set; } = string.Empty;
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public string EntityId { get; set; } = string.Empty;
|
||||||
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
public string MetadataJson { get; set; } = "{}";
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Backend.Domain;
|
||||||
|
|
||||||
|
public sealed class RiskFlag
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? SeasonId { get; set; }
|
||||||
|
public Season? Season { get; set; }
|
||||||
|
public string? TwitchUserId { get; set; }
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
public string Severity { get; set; } = "medium";
|
||||||
|
public string Status { get; set; } = "open";
|
||||||
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
public string CreatedFromIp { get; set; } = string.Empty;
|
||||||
|
public string UserAgent { get; set; } = string.Empty;
|
||||||
|
public string MetadataJson { get; set; } = "{}";
|
||||||
|
public string? ReviewedByTwitchId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ public sealed class UserSession
|
|||||||
public string TwitchUserId { get; set; } = string.Empty;
|
public string TwitchUserId { get; set; } = string.Empty;
|
||||||
public string DisplayName { get; set; } = string.Empty;
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
public string Role { get; set; } = "viewer";
|
public string Role { get; set; } = "viewer";
|
||||||
|
public string CreatedFromIp { get; set; } = string.Empty;
|
||||||
|
public string UserAgent { get; set; } = string.Empty;
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
public DateTimeOffset LastSeenAt { get; set; }
|
public DateTimeOffset LastSeenAt { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
|||||||
+358
-15
@@ -2,6 +2,7 @@ using Backend.Contracts;
|
|||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var connectionString = builder.Configuration["VTSA_POSTGRES"]
|
var connectionString = builder.Configuration["VTSA_POSTGRES"]
|
||||||
@@ -58,6 +59,79 @@ static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsD
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string ReadClientIp(HttpContext context) =>
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
static string ReadUserAgent(HttpContext context)
|
||||||
|
{
|
||||||
|
var value = context.Request.Headers.UserAgent.ToString().Trim();
|
||||||
|
return value.Length > 400 ? value[..400] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddAuditEntry(
|
||||||
|
AwardsDbContext db,
|
||||||
|
string adminTwitchUserId,
|
||||||
|
string actionType,
|
||||||
|
string entityType,
|
||||||
|
string entityId,
|
||||||
|
string summary,
|
||||||
|
object? metadata = null)
|
||||||
|
{
|
||||||
|
db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||||
|
{
|
||||||
|
AdminTwitchUserId = adminTwitchUserId,
|
||||||
|
ActionType = actionType,
|
||||||
|
EntityType = entityType,
|
||||||
|
EntityId = entityId,
|
||||||
|
Summary = summary,
|
||||||
|
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task AddRiskFlagIfMissingAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int? seasonId,
|
||||||
|
string? twitchUserId,
|
||||||
|
string source,
|
||||||
|
string type,
|
||||||
|
string severity,
|
||||||
|
string summary,
|
||||||
|
string createdFromIp,
|
||||||
|
string userAgent,
|
||||||
|
object? metadata = null)
|
||||||
|
{
|
||||||
|
var threshold = DateTimeOffset.UtcNow.AddHours(-6);
|
||||||
|
var exists = await db.RiskFlags.AnyAsync(item =>
|
||||||
|
item.Status == "open"
|
||||||
|
&& item.Source == source
|
||||||
|
&& item.Type == type
|
||||||
|
&& item.TwitchUserId == twitchUserId
|
||||||
|
&& item.CreatedFromIp == createdFromIp
|
||||||
|
&& item.SeasonId == seasonId
|
||||||
|
&& item.CreatedAt >= threshold);
|
||||||
|
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.RiskFlags.Add(new RiskFlag
|
||||||
|
{
|
||||||
|
SeasonId = seasonId,
|
||||||
|
TwitchUserId = twitchUserId,
|
||||||
|
Source = source,
|
||||||
|
Type = type,
|
||||||
|
Severity = severity,
|
||||||
|
Status = "open",
|
||||||
|
Summary = summary,
|
||||||
|
CreatedFromIp = createdFromIp,
|
||||||
|
UserAgent = userAgent,
|
||||||
|
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
@@ -86,15 +160,18 @@ using (var scope = app.Services.CreateScope())
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionBootstrapper.EnsureAsync(db);
|
await SessionBootstrapper.EnsureAsync(db);
|
||||||
|
await OperationalTablesBootstrapper.EnsureAsync(db);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// If the session table bootstrap fails, the rest of the API can still start.
|
// If the operational table bootstrap fails, the rest of the API can still start.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext db) =>
|
app.MapPost("/api/auth/dev-login", async (HttpContext context, LoginRequest request, AwardsDbContext db) =>
|
||||||
{
|
{
|
||||||
|
var createdFromIp = ReadClientIp(context);
|
||||||
|
var userAgent = ReadUserAgent(context);
|
||||||
var session = new UserSession
|
var session = new UserSession
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -102,11 +179,32 @@ app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext
|
|||||||
TwitchUserId = request.TwitchUserId.Trim(),
|
TwitchUserId = request.TwitchUserId.Trim(),
|
||||||
DisplayName = request.DisplayName.Trim(),
|
DisplayName = request.DisplayName.Trim(),
|
||||||
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
|
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
|
||||||
|
CreatedFromIp = createdFromIp,
|
||||||
|
UserAgent = userAgent,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
LastSeenAt = DateTimeOffset.UtcNow,
|
LastSeenAt = DateTimeOffset.UtcNow,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var recentSessionsFromIp = await db.UserSessions.CountAsync(item =>
|
||||||
|
item.CreatedFromIp == createdFromIp
|
||||||
|
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-15));
|
||||||
|
|
||||||
|
if (recentSessionsFromIp >= 3)
|
||||||
|
{
|
||||||
|
await AddRiskFlagIfMissingAsync(
|
||||||
|
db,
|
||||||
|
null,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"login",
|
||||||
|
"rapid_login_ip",
|
||||||
|
"medium",
|
||||||
|
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||||
|
createdFromIp,
|
||||||
|
userAgent,
|
||||||
|
new { recentSessionsFromIp });
|
||||||
|
}
|
||||||
|
|
||||||
db.UserSessions.Add(session);
|
db.UserSessions.Add(session);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -338,6 +436,25 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
|||||||
return Results.BadRequest(new { message = "A logged in user is required to submit nominations." });
|
return Results.BadRequest(new { message = "A logged in user is required to submit nominations." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var createdFromIp = ReadClientIp(context);
|
||||||
|
var userAgent = ReadUserAgent(context);
|
||||||
|
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||||
|
item.SeasonId == category.SeasonId
|
||||||
|
&& item.CategoryId == category.Id
|
||||||
|
&& item.SubmittedByTwitchId == submitterId);
|
||||||
|
|
||||||
|
var previousNominations = await db.Nominations
|
||||||
|
.Where(item =>
|
||||||
|
item.SeasonId == category.SeasonId
|
||||||
|
&& item.CategoryId == category.Id
|
||||||
|
&& item.SubmittedByTwitchId == submitterId)
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
if (previousNominations.Length > 0)
|
||||||
|
{
|
||||||
|
db.Nominations.RemoveRange(previousNominations);
|
||||||
|
}
|
||||||
|
|
||||||
var records = distinctNominees.Select(name => new Nomination
|
var records = distinctNominees.Select(name => new Nomination
|
||||||
{
|
{
|
||||||
SeasonId = category.SeasonId,
|
SeasonId = category.SeasonId,
|
||||||
@@ -348,9 +465,44 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
|||||||
});
|
});
|
||||||
|
|
||||||
await db.Nominations.AddRangeAsync(records);
|
await db.Nominations.AddRangeAsync(records);
|
||||||
|
|
||||||
|
var recentNominationVolume = await db.Nominations.CountAsync(item =>
|
||||||
|
item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||||
|
|
||||||
|
if (existingNominationCount > 0)
|
||||||
|
{
|
||||||
|
await AddRiskFlagIfMissingAsync(
|
||||||
|
db,
|
||||||
|
category.SeasonId,
|
||||||
|
submitterId,
|
||||||
|
"nomination",
|
||||||
|
"resubmitted_nomination",
|
||||||
|
"low",
|
||||||
|
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||||
|
createdFromIp,
|
||||||
|
userAgent,
|
||||||
|
new { categoryId = category.Id, existingNominationCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentNominationVolume >= 10)
|
||||||
|
{
|
||||||
|
await AddRiskFlagIfMissingAsync(
|
||||||
|
db,
|
||||||
|
category.SeasonId,
|
||||||
|
submitterId,
|
||||||
|
"nomination",
|
||||||
|
"rapid_nomination_burst",
|
||||||
|
"high",
|
||||||
|
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
|
||||||
|
createdFromIp,
|
||||||
|
userAgent,
|
||||||
|
new { recentNominationVolume });
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name });
|
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name, replacedPrevious = previousNominations.Length > 0 });
|
||||||
})
|
})
|
||||||
.WithName("CreateNomination")
|
.WithName("CreateNomination")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
@@ -379,24 +531,92 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
|
|||||||
return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
|
return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var ballot = new VoteBallot
|
var createdFromIp = ReadClientIp(context);
|
||||||
|
var userAgent = ReadUserAgent(context);
|
||||||
|
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
|
||||||
|
var validCandidates = await db.Candidates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
|
||||||
|
.Select(item => new { item.Id, item.CategoryId })
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
if (validCandidates.Length != candidateIds.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
|
||||||
|
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var ballot = await db.VoteBallots
|
||||||
|
.Include(item => item.Entries)
|
||||||
|
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||||
|
|
||||||
|
var isResubmission = ballot is not null;
|
||||||
|
if (ballot is null)
|
||||||
|
{
|
||||||
|
ballot = new VoteBallot
|
||||||
{
|
{
|
||||||
SeasonId = request.SeasonId,
|
SeasonId = request.SeasonId,
|
||||||
SubmittedByTwitchId = submitterId,
|
SubmittedByTwitchId = submitterId,
|
||||||
SubmittedAt = DateTimeOffset.UtcNow,
|
|
||||||
Status = "submitted",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await db.VoteBallots.AddAsync(ballot);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||||
|
ballot.Entries.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
|
ballot.Status = "submitted";
|
||||||
ballot.Entries = request.Entries.Select(entry => new VoteEntry
|
ballot.Entries = request.Entries.Select(entry => new VoteEntry
|
||||||
{
|
{
|
||||||
CategoryId = entry.CategoryId,
|
CategoryId = entry.CategoryId,
|
||||||
CandidateId = entry.CandidateId,
|
CandidateId = entry.CandidateId,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
await db.VoteBallots.AddAsync(ballot);
|
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
|
||||||
|
item.SubmittedByTwitchId == submitterId
|
||||||
|
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||||
|
|
||||||
|
if (isResubmission)
|
||||||
|
{
|
||||||
|
await AddRiskFlagIfMissingAsync(
|
||||||
|
db,
|
||||||
|
request.SeasonId,
|
||||||
|
submitterId,
|
||||||
|
"vote",
|
||||||
|
"resubmitted_ballot",
|
||||||
|
"low",
|
||||||
|
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
||||||
|
createdFromIp,
|
||||||
|
userAgent,
|
||||||
|
new { entryCount = request.Entries.Length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentVoteSubmissions >= 3)
|
||||||
|
{
|
||||||
|
await AddRiskFlagIfMissingAsync(
|
||||||
|
db,
|
||||||
|
request.SeasonId,
|
||||||
|
submitterId,
|
||||||
|
"vote",
|
||||||
|
"rapid_vote_updates",
|
||||||
|
"high",
|
||||||
|
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
|
||||||
|
createdFromIp,
|
||||||
|
userAgent,
|
||||||
|
new { recentVoteSubmissions });
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count });
|
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||||
})
|
})
|
||||||
.WithName("CreateVote")
|
.WithName("CreateVote")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
@@ -433,6 +653,43 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
|||||||
.Take(5)
|
.Take(5)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
var riskFlags = await db.RiskFlags
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.Status == "open")
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(8)
|
||||||
|
.Select(item => new AdminRiskFlagDto(
|
||||||
|
item.Id,
|
||||||
|
item.Source,
|
||||||
|
item.Type,
|
||||||
|
item.Severity,
|
||||||
|
item.Status,
|
||||||
|
item.Summary,
|
||||||
|
item.TwitchUserId,
|
||||||
|
item.CreatedFromIp,
|
||||||
|
item.CreatedAt,
|
||||||
|
item.MetadataJson))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var auditEntries = await db.AdminAuditEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
|
.Take(8)
|
||||||
|
.Select(item => new AdminAuditEntryDto(
|
||||||
|
item.Id,
|
||||||
|
item.AdminTwitchUserId,
|
||||||
|
item.ActionType,
|
||||||
|
item.EntityType,
|
||||||
|
item.EntityId,
|
||||||
|
item.Summary,
|
||||||
|
item.CreatedAt))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
var activityItems = auditEntries
|
||||||
|
.Take(3)
|
||||||
|
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var response = new AdminDashboardResponse(
|
var response = new AdminDashboardResponse(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
@@ -441,13 +698,10 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
|||||||
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
|
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
|
||||||
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
|
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
|
||||||
},
|
},
|
||||||
new[]
|
activityItems,
|
||||||
{
|
topCategories,
|
||||||
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
|
riskFlags,
|
||||||
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
|
auditEntries);
|
||||||
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
|
|
||||||
},
|
|
||||||
topCategories);
|
|
||||||
|
|
||||||
return Results.Ok(response);
|
return Results.Ok(response);
|
||||||
})
|
})
|
||||||
@@ -594,6 +848,14 @@ app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
season.IsCurrent = request.IsCurrent;
|
season.IsCurrent = request.IsCurrent;
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"season.update",
|
||||||
|
"season",
|
||||||
|
season.Id.ToString(),
|
||||||
|
$"Season {season.Year} wurde aktualisiert.",
|
||||||
|
new { request.CurrentPhase, request.IsCurrent });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, seasonId = season.Id });
|
return Results.Ok(new { saved = true, seasonId = season.Id });
|
||||||
@@ -627,6 +889,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext c
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.Categories.Add(category);
|
db.Categories.Add(category);
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category.create",
|
||||||
|
"category",
|
||||||
|
request.Slug.Trim(),
|
||||||
|
$"Kategorie {request.Name.Trim()} wurde angelegt.",
|
||||||
|
new { seasonId, request.GroupName, request.SortOrder });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||||
@@ -655,6 +925,14 @@ app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context,
|
|||||||
category.SortOrder = request.SortOrder;
|
category.SortOrder = request.SortOrder;
|
||||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||||
|
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"category.update",
|
||||||
|
"category",
|
||||||
|
category.Id.ToString(),
|
||||||
|
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
|
||||||
|
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||||
@@ -686,6 +964,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext c
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.Candidates.Add(candidate);
|
db.Candidates.Add(candidate);
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"candidate.create",
|
||||||
|
"candidate",
|
||||||
|
request.DisplayName.Trim(),
|
||||||
|
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||||
|
new { seasonId, request.CategoryId, request.Platform });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||||
@@ -712,6 +998,14 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
|
|||||||
candidate.ChannelSlug = request.ChannelSlug.Trim();
|
candidate.ChannelSlug = request.ChannelSlug.Trim();
|
||||||
candidate.Platform = request.Platform.Trim();
|
candidate.Platform = request.Platform.Trim();
|
||||||
|
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"candidate.update",
|
||||||
|
"candidate",
|
||||||
|
candidate.Id.ToString(),
|
||||||
|
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||||
|
new { request.CategoryId, request.Platform });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||||
@@ -783,6 +1077,14 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpCont
|
|||||||
|
|
||||||
nomination.CandidateId = candidate.Id;
|
nomination.CandidateId = candidate.Id;
|
||||||
nomination.CandidateText = null;
|
nomination.CandidateText = null;
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"nomination.approve",
|
||||||
|
"nomination",
|
||||||
|
nomination.Id.ToString(),
|
||||||
|
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||||
|
new { candidateId = candidate.Id, created = existingCandidate is null });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||||
@@ -806,6 +1108,13 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
|
|||||||
|
|
||||||
nomination.CandidateText = null;
|
nomination.CandidateText = null;
|
||||||
nomination.CandidateId = null;
|
nomination.CandidateId = null;
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"nomination.reject",
|
||||||
|
"nomination",
|
||||||
|
nomination.Id.ToString(),
|
||||||
|
$"Nominierung {nomination.Id} wurde verworfen.");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||||
@@ -813,4 +1122,38 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
|
|||||||
.WithName("RejectAdminNomination")
|
.WithName("RejectAdminNomination")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|
||||||
|
app.MapPost("/api/admin/risk-flags/{riskFlagId:int}/resolve", async (HttpContext context, int riskFlagId, ResolveRiskFlagRequest request, AwardsDbContext db) =>
|
||||||
|
{
|
||||||
|
var session = await ResolveSessionAsync(context, db);
|
||||||
|
if (session?.Role != "admin")
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
|
||||||
|
if (riskFlag is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
riskFlag.Status = string.IsNullOrWhiteSpace(request.Status) ? "resolved" : request.Status.Trim().ToLowerInvariant();
|
||||||
|
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
|
||||||
|
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
AddAuditEntry(
|
||||||
|
db,
|
||||||
|
session.TwitchUserId,
|
||||||
|
"risk.resolve",
|
||||||
|
"risk-flag",
|
||||||
|
riskFlag.Id.ToString(),
|
||||||
|
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
|
||||||
|
new { riskFlag.Type, riskFlag.Source });
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
|
||||||
|
})
|
||||||
|
.WithName("ResolveRiskFlag")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ The frontend uses a lightweight local session flow for development:
|
|||||||
- Sign in from the header
|
- Sign in from the header
|
||||||
- `Viewer Login` unlocks nomination and voting
|
- `Viewer Login` unlocks nomination and voting
|
||||||
- `Admin Login` unlocks the admin routes and management views
|
- `Admin Login` unlocks the admin routes and management views
|
||||||
|
- Voting and nominations can be resubmitted; the backend updates the existing user state instead of blindly duplicating submissions
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
@@ -85,3 +86,4 @@ curl -X POST http://localhost:5084/api/auth/dev-login \
|
|||||||
- Session endpoints live under `/api/auth/*`
|
- Session endpoints live under `/api/auth/*`
|
||||||
- Database connectivity and pending migrations are exposed at `/api/health/database`
|
- Database connectivity and pending migrations are exposed at `/api/health/database`
|
||||||
- Current frontend store falls back to static seed-like data if the API is unavailable
|
- Current frontend store falls back to static seed-like data if the API is unavailable
|
||||||
|
- The admin dashboard now includes a lightweight risk center and audit log for suspicious submit patterns and reviewed admin actions
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VITE_API_URL=http://localhost:5084
|
VITE_API_URL=http://127.0.0.1:5084
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const currentLabel = computed(
|
const currentLabel = computed(
|
||||||
() => navItems.find((item) => item.to === route.path)?.label ?? 'Awards',
|
() => navItems.find((item) => route.path === item.to || route.path.startsWith(`${item.to}/`))?.label ?? 'Awards',
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleNavItems = computed(() =>
|
const visibleNavItems = computed(() =>
|
||||||
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
|
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
|
||||||
)
|
)
|
||||||
|
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||||
|
|
||||||
async function login(role: 'viewer' | 'admin') {
|
async function login(role: 'viewer' | 'admin') {
|
||||||
loginError.value = ''
|
loginError.value = ''
|
||||||
@@ -56,15 +57,21 @@ async function login(role: 'viewer' | 'admin') {
|
|||||||
<span>{{ currentLabel }}</span>
|
<span>{{ currentLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="mb-10 flex flex-col gap-6 rounded-[34px] border border-white/70 bg-white/72 px-5 py-5 shadow-[0_24px_80px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7">
|
<header
|
||||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
class="flex flex-col rounded-[24px] border border-white/70 bg-white/72 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7"
|
||||||
|
:class="isAdminRoute ? 'mb-5 gap-3 px-4 py-3' : 'mb-10 gap-6 px-5 py-5'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between" :class="isAdminRoute ? 'gap-3' : 'gap-5'">
|
||||||
<RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
|
<RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
|
||||||
<div class="grid h-12 w-12 place-items-center rounded-[1.4rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]">
|
<div
|
||||||
<Star class="h-5 w-5" />
|
class="grid place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]"
|
||||||
|
:class="isAdminRoute ? 'h-10 w-10' : 'h-12 w-12'"
|
||||||
|
>
|
||||||
|
<Star :class="isAdminRoute ? 'h-4 w-4' : 'h-5 w-5'" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
|
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
|
||||||
<span class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
|
<span v-if="!isAdminRoute" class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
@@ -73,10 +80,10 @@ async function login(role: 'viewer' | 'admin') {
|
|||||||
<div class="rounded-full border border-violet-100 bg-violet-50/70 px-4 py-2 text-sm text-violet-800">
|
<div class="rounded-full border border-violet-100 bg-violet-50/70 px-4 py-2 text-sm text-violet-800">
|
||||||
{{ authStore.session.displayName }} · {{ authStore.session.role }}
|
{{ authStore.session.displayName }} · {{ authStore.session.role }}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" @click="authStore.logout()">Logout</Button>
|
<Button variant="ghost" @click="authStore.logout()">Abmelden</Button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button variant="ghost" @click="loginOpen = !loginOpen">Sign in</Button>
|
<Button variant="ghost" @click="loginOpen = !loginOpen">Einloggen</Button>
|
||||||
<Button @click="login('viewer')">Mit Twitch Login</Button>
|
<Button @click="login('viewer')">Mit Twitch Login</Button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,26 +91,26 @@ async function login(role: 'viewer' | 'admin') {
|
|||||||
|
|
||||||
<div v-if="loginOpen && !authStore.session" class="rounded-[28px] border border-violet-100 bg-white/80 p-5">
|
<div v-if="loginOpen && !authStore.session" class="rounded-[28px] border border-violet-100 bg-white/80 p-5">
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<input v-model="loginForm.displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
<input v-model="loginForm.displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Anzeigename" />
|
||||||
<input v-model="loginForm.twitchUserId" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Twitch User ID" />
|
<input v-model="loginForm.twitchUserId" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Twitch Nutzer-ID" />
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button :disabled="authStore.loading" @click="login('viewer')">
|
<Button :disabled="authStore.loading" @click="login('viewer')">
|
||||||
{{ authStore.loading ? 'Loggt ein ...' : 'Viewer Login' }}
|
{{ authStore.loading ? 'Loggt ein ...' : 'Viewer-Login' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" :disabled="authStore.loading" @click="login('admin')">Admin Login</Button>
|
<Button variant="secondary" :disabled="authStore.loading" @click="login('admin')">Admin-Login</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="loginError" class="mt-3 text-sm text-rose-700">{{ loginError }}</p>
|
<p v-if="loginError" class="mt-3 text-sm text-rose-700">{{ loginError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 border-t border-black/6 pt-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-3 border-t border-black/6 pt-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
|
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in visibleNavItems"
|
v-for="item in visibleNavItems"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
|
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
|
||||||
:class="route.path === item.to ? 'bg-violet-100 text-violet-800' : ''"
|
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
eyebrow?: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 border-b border-violet-100 pb-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p v-if="eyebrow" class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">{{ eyebrow }}</p>
|
||||||
|
<h2 class="mt-1 text-2xl font-semibold text-slate-900">{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
<p class="max-w-xl text-sm leading-6 text-slate-500">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Select from 'primevue/select'
|
||||||
|
import { CalendarCog, CheckCircle2, Clock3, Sparkles, Tags, Users } from '@lucide/vue'
|
||||||
|
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
|
||||||
|
const seasons = computed(() => store.adminSeasons)
|
||||||
|
const selectedSeasonId = computed({
|
||||||
|
get: () => store.adminSelectedSeasonId,
|
||||||
|
set: async (value) => {
|
||||||
|
if (!value) return
|
||||||
|
await store.loadAdminSeasonDetail(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const seasonOptions = computed(() =>
|
||||||
|
seasons.value.map((season) => ({
|
||||||
|
label: `${season.year} · ${season.name}`,
|
||||||
|
value: season.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentSeason = computed(() => store.adminSeasonDetail)
|
||||||
|
const yearStats = computed(() => [
|
||||||
|
{ label: 'Kategorien', value: currentSeason.value.categories.length, icon: Tags },
|
||||||
|
{ label: 'Kandidaten', value: currentSeason.value.candidates.length, icon: Users },
|
||||||
|
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length, icon: Sparkles },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-[26px] border border-violet-100 bg-white/85 p-4 shadow-[0_18px_46px_rgba(168,145,214,0.09)]">
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-center">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<CalendarCog class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktuelles Award-Jahr</p>
|
||||||
|
<h2 class="truncate text-xl font-semibold text-slate-900">
|
||||||
|
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswaehlen' }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 lg:ml-auto">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1.5 text-xs font-semibold text-slate-700">
|
||||||
|
<Clock3 class="h-3.5 w-3.5 text-violet-600" />
|
||||||
|
{{ currentSeason.currentPhase || 'Kein Status' }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold" :class="currentSeason.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
|
||||||
|
<CheckCircle2 class="h-3.5 w-3.5" />
|
||||||
|
{{ currentSeason.isCurrent ? 'Oeffentlich sichtbar' : 'Nicht oeffentlich' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Jahr wechseln</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedSeasonId"
|
||||||
|
:options="seasonOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-2 sm:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="item in yearStats"
|
||||||
|
:key="item.label"
|
||||||
|
class="flex items-center justify-between rounded-2xl border border-violet-50 bg-violet-50/40 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||||
|
<component :is="item.icon" class="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
<strong class="text-violet-800">{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+11
-1
@@ -15,7 +15,7 @@ import type {
|
|||||||
WinnerArchiveResponse,
|
WinnerArchiveResponse,
|
||||||
} from '../types/awards'
|
} from '../types/awards'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:5084'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:5084'
|
||||||
const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
||||||
|
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
@@ -31,6 +31,8 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
}).catch(() => {
|
||||||
|
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API request failed for ${path}`)
|
throw new Error(`API request failed for ${path}`)
|
||||||
@@ -47,6 +49,8 @@ async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: u
|
|||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
}).catch(() => {
|
||||||
|
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -96,6 +100,12 @@ export const api = {
|
|||||||
'POST',
|
'POST',
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
|
resolveRiskFlag: (riskFlagId: number, status = 'resolved') =>
|
||||||
|
sendJson<{ saved: boolean; riskFlagId: number; status: string }>(
|
||||||
|
`/api/admin/risk-flags/${riskFlagId}/resolve`,
|
||||||
|
'POST',
|
||||||
|
{ status },
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export { AUTH_TOKEN_KEY }
|
export { AUTH_TOKEN_KEY }
|
||||||
|
|||||||
+131
-4
@@ -1,7 +1,19 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
import AdminView from './views/AdminView.vue'
|
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
|
||||||
|
import AdminAnalyticsView from './views/admin/AdminAnalyticsView.vue'
|
||||||
|
import AdminCategoriesView from './views/admin/AdminCategoriesView.vue'
|
||||||
|
import AdminClipsView from './views/admin/AdminClipsView.vue'
|
||||||
|
import AdminDashboardView from './views/admin/AdminDashboardView.vue'
|
||||||
|
import AdminLayoutView from './views/admin/AdminLayoutView.vue'
|
||||||
|
import AdminNominationsView from './views/admin/AdminNominationsView.vue'
|
||||||
|
import AdminReviewsView from './views/admin/AdminReviewsView.vue'
|
||||||
|
import AdminRiskView from './views/admin/AdminRiskView.vue'
|
||||||
|
import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
|
||||||
|
import AdminSettingsView from './views/admin/AdminSettingsView.vue'
|
||||||
|
import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue'
|
||||||
|
import AdminVotingView from './views/admin/AdminVotingView.vue'
|
||||||
import HomeView from './views/HomeView.vue'
|
import HomeView from './views/HomeView.vue'
|
||||||
import NominationsView from './views/NominationsView.vue'
|
import NominationsView from './views/NominationsView.vue'
|
||||||
import VotingView from './views/VotingView.vue'
|
import VotingView from './views/VotingView.vue'
|
||||||
@@ -9,7 +21,15 @@ import WinnersView from './views/WinnersView.vue'
|
|||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
scrollBehavior() {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
|
if (savedPosition) {
|
||||||
|
return savedPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path.startsWith('/admin') && from.path.startsWith('/admin')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return { top: 0 }
|
return { top: 0 }
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -24,6 +44,7 @@ const router = createRouter({
|
|||||||
component: NominationsView,
|
component: NominationsView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,6 +53,7 @@ const router = createRouter({
|
|||||||
component: VotingView,
|
component: VotingView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,11 +63,116 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
component: AdminLayoutView,
|
||||||
component: AdminView,
|
|
||||||
meta: {
|
meta: {
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
},
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: { name: 'admin-dashboard' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
component: AdminDashboardView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'seasons',
|
||||||
|
redirect: { name: 'admin-years' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'years',
|
||||||
|
name: 'admin-years',
|
||||||
|
component: AdminSeasonsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'nominations',
|
||||||
|
name: 'admin-nominations',
|
||||||
|
component: AdminNominationsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'voting',
|
||||||
|
name: 'admin-voting',
|
||||||
|
component: AdminVotingView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'categories',
|
||||||
|
name: 'admin-categories',
|
||||||
|
component: AdminCategoriesView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'candidates',
|
||||||
|
name: 'admin-candidates',
|
||||||
|
component: AdminCandidatesView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'clips',
|
||||||
|
name: 'admin-clips',
|
||||||
|
component: AdminClipsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reviews',
|
||||||
|
name: 'admin-reviews',
|
||||||
|
component: AdminReviewsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'risk',
|
||||||
|
name: 'admin-risk',
|
||||||
|
component: AdminRiskView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users-logs',
|
||||||
|
name: 'admin-users-logs',
|
||||||
|
component: AdminUsersLogsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'analytics',
|
||||||
|
name: 'admin-analytics',
|
||||||
|
component: AdminAnalyticsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'admin-settings',
|
||||||
|
component: AdminSettingsView,
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -89,25 +89,50 @@ const fallbackArchive: WinnerArchiveResponse = {
|
|||||||
const fallbackAdmin: AdminDashboardResponse = {
|
const fallbackAdmin: AdminDashboardResponse = {
|
||||||
metrics: [
|
metrics: [
|
||||||
{ label: 'Nominierungen', value: 12341, note: '+12.4% vs. gestern' },
|
{ label: 'Nominierungen', value: 12341, note: '+12.4% vs. gestern' },
|
||||||
{ label: 'Votes', value: 587231, note: '+8.7% vs. gestern' },
|
{ label: 'Stimmen', value: 587231, note: '+8.7% vs. gestern' },
|
||||||
{ label: 'Kategorien', value: 28, note: 'aktiv im Jahr 2026' },
|
{ label: 'Kategorien', value: 28, note: 'aktiv im Jahr 2026' },
|
||||||
{ label: 'Reviews offen', value: 47, note: '14 neu' },
|
{ label: 'Reviews offen', value: 47, note: '14 neu' },
|
||||||
],
|
],
|
||||||
activities: [
|
activities: [
|
||||||
{ label: 'Neue Nominierung in Best New VTuber', age: 'vor 2 Min.' },
|
{ label: 'Neue Nominierung in Bester neuer VTuber', age: 'vor 2 Min.' },
|
||||||
{ label: 'Clip-Dublette erkannt in Clip des Jahres', age: 'vor 7 Min.' },
|
{ label: 'Clip-Dublette erkannt in Clip des Jahres', age: 'vor 7 Min.' },
|
||||||
{ label: 'Alias-Merge fuer Hoshimi Miyu reviewt', age: 'vor 18 Min.' },
|
{ label: 'Alias-Zusammenfuehrung fuer Hoshimi Miyu geprueft', age: 'vor 18 Min.' },
|
||||||
],
|
],
|
||||||
topCategories: [
|
topCategories: [
|
||||||
{ category: 'VTuber des Jahres', votes: 186321 },
|
{ category: 'VTuber des Jahres', votes: 186321 },
|
||||||
{ category: 'Bestes Live Event', votes: 132550 },
|
{ category: 'Bestes Live Event', votes: 132550 },
|
||||||
{ category: 'Clip des Jahres', votes: 98210 },
|
{ category: 'Clip des Jahres', votes: 98210 },
|
||||||
],
|
],
|
||||||
|
riskFlags: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
source: 'vote',
|
||||||
|
type: 'rapid_vote_updates',
|
||||||
|
severity: 'high',
|
||||||
|
status: 'open',
|
||||||
|
summary: 'Mehrere Voting-Aenderungen in kurzer Zeit erkannt.',
|
||||||
|
twitchUserId: 'demo_user',
|
||||||
|
createdFromIp: '127.0.0.1',
|
||||||
|
createdAt: '2026-06-17T08:40:00Z',
|
||||||
|
metadataJson: '{"recentVoteSubmissions":3}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
auditEntries: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
adminTwitchUserId: 'jayuhime_admin',
|
||||||
|
actionType: 'category.update',
|
||||||
|
entityType: 'category',
|
||||||
|
entityId: '1',
|
||||||
|
summary: 'Kategorie VTuber des Jahres wurde aktualisiert.',
|
||||||
|
createdAt: '2026-06-17T08:32:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackAdminSeasons: AdminSeasonListItem[] = [
|
const fallbackAdminSeasons: AdminSeasonListItem[] = [
|
||||||
{ id: 1, year: 2026, name: 'VTuber Star Awards 2026', currentPhase: 'Community Voting', isCurrent: true, categoryCount: 4 },
|
{ id: 1, year: 2026, name: 'VTuber Star Awards 2026', currentPhase: 'Community Voting', isCurrent: true, categoryCount: 4 },
|
||||||
{ id: 2, year: 2025, name: 'VTuber Star Awards 2025', currentPhase: 'Archived', isCurrent: false, categoryCount: 3 },
|
{ id: 2, year: 2025, name: 'VTuber Star Awards 2025', currentPhase: 'Archiviert', isCurrent: false, categoryCount: 3 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
|
const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
|
||||||
@@ -119,7 +144,7 @@ const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
|
|||||||
categories: [
|
categories: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
groupName: 'Main Awards',
|
groupName: 'Hauptpreise',
|
||||||
name: 'VTuber des Jahres',
|
name: 'VTuber des Jahres',
|
||||||
slug: 'vtuber-des-jahres',
|
slug: 'vtuber-des-jahres',
|
||||||
description: 'Die groesste Auszeichnung des Jahres.',
|
description: 'Die groesste Auszeichnung des Jahres.',
|
||||||
@@ -158,6 +183,8 @@ const emptyAdmin: AdminDashboardResponse = {
|
|||||||
metrics: [],
|
metrics: [],
|
||||||
activities: [],
|
activities: [],
|
||||||
topCategories: [],
|
topCategories: [],
|
||||||
|
riskFlags: [],
|
||||||
|
auditEntries: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyAdminSeasons: AdminSeasonListItem[] = []
|
const emptyAdminSeasons: AdminSeasonListItem[] = []
|
||||||
@@ -181,6 +208,7 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
admin: fallbackAdmin as AdminDashboardResponse,
|
admin: fallbackAdmin as AdminDashboardResponse,
|
||||||
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
|
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
|
||||||
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
|
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
|
||||||
|
adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
apiMode: 'fallback' as 'api' | 'fallback',
|
apiMode: 'fallback' as 'api' | 'fallback',
|
||||||
}),
|
}),
|
||||||
@@ -210,22 +238,39 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
try {
|
try {
|
||||||
this.admin = await api.getAdminDashboard()
|
this.admin = await api.getAdminDashboard()
|
||||||
this.adminSeasons = await api.getAdminSeasons()
|
this.adminSeasons = await api.getAdminSeasons()
|
||||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSeasons[0]?.id ?? 1)
|
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
|
||||||
|
this.adminSelectedSeasonId = this.adminSeasons[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.adminSelectedSeasonId) {
|
||||||
|
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||||
|
}
|
||||||
this.apiMode = 'api'
|
this.apiMode = 'api'
|
||||||
} catch {
|
} catch {
|
||||||
this.admin = emptyAdmin
|
this.admin = emptyAdmin
|
||||||
this.adminSeasons = emptyAdminSeasons
|
this.adminSeasons = emptyAdminSeasons
|
||||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||||
|
this.adminSelectedSeasonId = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadAdminSeasonDetail(seasonId: number) {
|
async loadAdminSeasonDetail(seasonId: number) {
|
||||||
try {
|
try {
|
||||||
|
this.adminSelectedSeasonId = seasonId
|
||||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
|
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
|
||||||
this.apiMode = 'api'
|
this.apiMode = 'api'
|
||||||
} catch {
|
} catch {
|
||||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async initializeAdminWorkspace() {
|
||||||
|
await this.loadAdmin()
|
||||||
|
if (this.adminSelectedSeasonId && this.adminSeasonDetail.id !== this.adminSelectedSeasonId) {
|
||||||
|
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAdminSeason(seasonId: number) {
|
||||||
|
this.adminSelectedSeasonId = seasonId
|
||||||
|
},
|
||||||
submitNomination(payload: CreateNominationPayload) {
|
submitNomination(payload: CreateNominationPayload) {
|
||||||
return api.submitNomination(payload)
|
return api.submitNomination(payload)
|
||||||
},
|
},
|
||||||
@@ -269,5 +314,10 @@ export const useAwardsStore = defineStore('awards', {
|
|||||||
await this.loadAdmin()
|
await this.loadAdmin()
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
async resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||||
|
const result = await api.resolveRiskFlag(riskFlagId, status)
|
||||||
|
await this.loadAdmin()
|
||||||
|
return result
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -89,10 +89,35 @@ export interface AdminTopCategory {
|
|||||||
votes: number
|
votes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminRiskFlag {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
type: string
|
||||||
|
severity: string
|
||||||
|
status: string
|
||||||
|
summary: string
|
||||||
|
twitchUserId: string | null
|
||||||
|
createdFromIp: string
|
||||||
|
createdAt: string
|
||||||
|
metadataJson: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAuditEntry {
|
||||||
|
id: number
|
||||||
|
adminTwitchUserId: string
|
||||||
|
actionType: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
summary: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminDashboardResponse {
|
export interface AdminDashboardResponse {
|
||||||
metrics: AdminMetric[]
|
metrics: AdminMetric[]
|
||||||
activities: AdminActivity[]
|
activities: AdminActivity[]
|
||||||
topCategories: AdminTopCategory[]
|
topCategories: AdminTopCategory[]
|
||||||
|
riskFlags: AdminRiskFlag[]
|
||||||
|
auditEntries: AdminAuditEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSeasonListItem {
|
export interface AdminSeasonListItem {
|
||||||
|
|||||||
@@ -1,560 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import Column from 'primevue/column'
|
|
||||||
import DataTable from 'primevue/datatable'
|
|
||||||
import Select from 'primevue/select'
|
|
||||||
|
|
||||||
import Button from '../components/ui/Button.vue'
|
|
||||||
import Card from '../components/ui/Card.vue'
|
|
||||||
import { useAwardsStore } from '../stores/awards'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
|
|
||||||
const store = useAwardsStore()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const selectedSeasonId = ref<number | null>(null)
|
|
||||||
const seasonSaving = ref(false)
|
|
||||||
const categorySaving = ref<number | 'new' | null>(null)
|
|
||||||
const candidateSaving = ref<number | 'new' | null>(null)
|
|
||||||
const reviewSaving = ref<number | null>(null)
|
|
||||||
const adminMessage = ref('')
|
|
||||||
const adminError = ref('')
|
|
||||||
|
|
||||||
const seasonForm = reactive({
|
|
||||||
currentPhase: '',
|
|
||||||
isCurrent: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCategoryForm = reactive({
|
|
||||||
groupName: '',
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
description: '',
|
|
||||||
sortOrder: 1,
|
|
||||||
maxNomineesPerUser: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCandidateForm = reactive({
|
|
||||||
categoryId: 0,
|
|
||||||
displayName: '',
|
|
||||||
channelSlug: '',
|
|
||||||
platform: 'Twitch',
|
|
||||||
})
|
|
||||||
|
|
||||||
const editForms = reactive<Record<number, {
|
|
||||||
groupName: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
description: string
|
|
||||||
sortOrder: number
|
|
||||||
maxNomineesPerUser: number
|
|
||||||
}>>({})
|
|
||||||
|
|
||||||
const candidateForms = reactive<Record<number, {
|
|
||||||
categoryId: number
|
|
||||||
displayName: string
|
|
||||||
channelSlug: string
|
|
||||||
platform: string
|
|
||||||
}>>({})
|
|
||||||
|
|
||||||
const reviewForms = reactive<Record<number, {
|
|
||||||
displayName: string
|
|
||||||
channelSlug: string
|
|
||||||
platform: string
|
|
||||||
}>>({})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!authStore.isAdmin) return
|
|
||||||
await store.loadAdmin()
|
|
||||||
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const metrics = computed(() => store.admin.metrics)
|
|
||||||
const activities = computed(() => store.admin.activities)
|
|
||||||
const topCategories = computed(() => store.admin.topCategories)
|
|
||||||
const seasons = computed(() => store.adminSeasons)
|
|
||||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
|
||||||
const categoryOptions = computed(() =>
|
|
||||||
seasonDetail.value.categories.map((category) => ({
|
|
||||||
label: `${category.groupName} · ${category.name}`,
|
|
||||||
value: category.id,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(selectedSeasonId, async (seasonId) => {
|
|
||||||
if (!seasonId) return
|
|
||||||
await store.loadAdminSeasonDetail(seasonId)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
seasonDetail,
|
|
||||||
(detail) => {
|
|
||||||
seasonForm.currentPhase = detail.currentPhase
|
|
||||||
seasonForm.isCurrent = detail.isCurrent
|
|
||||||
|
|
||||||
for (const category of detail.categories) {
|
|
||||||
editForms[category.id] = {
|
|
||||||
groupName: category.groupName,
|
|
||||||
name: category.name,
|
|
||||||
slug: category.slug,
|
|
||||||
description: category.description,
|
|
||||||
sortOrder: category.sortOrder,
|
|
||||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of detail.candidates) {
|
|
||||||
candidateForms[candidate.id] = {
|
|
||||||
categoryId: candidate.categoryId,
|
|
||||||
displayName: candidate.displayName,
|
|
||||||
channelSlug: candidate.channelSlug,
|
|
||||||
platform: candidate.platform,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const nomination of detail.pendingNominations) {
|
|
||||||
reviewForms[nomination.id] = {
|
|
||||||
displayName: nomination.candidateText,
|
|
||||||
channelSlug: '',
|
|
||||||
platform: 'Twitch',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const seasonOptions = computed(() =>
|
|
||||||
seasons.value.map((season) => ({
|
|
||||||
label: `${season.year} · ${season.name}`,
|
|
||||||
value: season.id,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function saveSeason() {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
seasonSaving.value = true
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.updateAdminSeason(selectedSeasonId.value, {
|
|
||||||
currentPhase: seasonForm.currentPhase,
|
|
||||||
isCurrent: seasonForm.isCurrent,
|
|
||||||
})
|
|
||||||
await store.loadAdminSeasonDetail(selectedSeasonId.value)
|
|
||||||
adminMessage.value = 'Season-Einstellungen gespeichert.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
seasonSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCategory(categoryId: number) {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
categorySaving.value = categoryId
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
|
||||||
adminMessage.value = 'Kategorie gespeichert.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
categorySaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCategory() {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
categorySaving.value = 'new'
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
|
||||||
adminMessage.value = 'Neue Kategorie angelegt.'
|
|
||||||
newCategoryForm.groupName = ''
|
|
||||||
newCategoryForm.name = ''
|
|
||||||
newCategoryForm.slug = ''
|
|
||||||
newCategoryForm.description = ''
|
|
||||||
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
|
|
||||||
newCategoryForm.maxNomineesPerUser = 3
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
|
||||||
} finally {
|
|
||||||
categorySaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCandidate(candidateId: number) {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
candidateSaving.value = candidateId
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
|
||||||
adminMessage.value = 'Kandidat gespeichert.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
|
||||||
} finally {
|
|
||||||
candidateSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCandidate() {
|
|
||||||
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
|
|
||||||
|
|
||||||
candidateSaving.value = 'new'
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
|
||||||
adminMessage.value = 'Kandidat angelegt.'
|
|
||||||
newCandidateForm.displayName = ''
|
|
||||||
newCandidateForm.channelSlug = ''
|
|
||||||
newCandidateForm.platform = 'Twitch'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
|
||||||
} finally {
|
|
||||||
candidateSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function approveNomination(nominationId: number) {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
reviewSaving.value = nominationId
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
|
||||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
|
|
||||||
} finally {
|
|
||||||
reviewSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rejectNomination(nominationId: number) {
|
|
||||||
if (!selectedSeasonId.value) return
|
|
||||||
|
|
||||||
reviewSaving.value = nominationId
|
|
||||||
adminMessage.value = ''
|
|
||||||
adminError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
|
|
||||||
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
|
|
||||||
} catch (error) {
|
|
||||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
|
||||||
} finally {
|
|
||||||
reviewSaving.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-10 pb-14">
|
|
||||||
<Card v-if="!authStore.isAdmin" class="p-8">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
|
|
||||||
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
|
|
||||||
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
|
|
||||||
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
|
|
||||||
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
|
|
||||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
|
||||||
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-5 lg:grid-cols-4">
|
|
||||||
<Card
|
|
||||||
v-for="metric in metrics"
|
|
||||||
:key="metric.label"
|
|
||||||
class="p-7"
|
|
||||||
>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
|
|
||||||
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="text-sm font-semibold text-slate-600">Season</label>
|
|
||||||
<Select
|
|
||||||
v-model="selectedSeasonId"
|
|
||||||
:options="seasonOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="text-sm font-semibold text-slate-600">Phase</label>
|
|
||||||
<input
|
|
||||||
v-model="seasonForm.currentPhase"
|
|
||||||
type="text"
|
|
||||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
|
|
||||||
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
|
|
||||||
Diese Season ist die aktuelle Public Season
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
|
||||||
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
|
||||||
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
|
|
||||||
</Button>
|
|
||||||
<span class="text-sm text-slate-500">
|
|
||||||
{{ seasonDetail.year }} · {{ seasonDetail.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
|
||||||
{{ adminMessage }}
|
|
||||||
</p>
|
|
||||||
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
|
||||||
{{ adminError }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
|
|
||||||
<DataTable :value="topCategories" class="mt-6" striped-rows>
|
|
||||||
<Column field="category" header="Kategorie" />
|
|
||||||
<Column field="votes" header="Votes">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ Number(data.votes).toLocaleString('de-DE') }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ seasonDetail.categories.length }} Kategorien
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="category in seasonDetail.categories"
|
|
||||||
:key="category.id"
|
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
|
|
||||||
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
|
|
||||||
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
|
||||||
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
|
||||||
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
|
|
||||||
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
|
||||||
{{ category.candidateCount }} Kandidaten in dieser Kategorie
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
v-model="editForms[category.id].description"
|
|
||||||
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="Beschreibung"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
|
|
||||||
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
|
|
||||||
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
|
|
||||||
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
|
||||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
|
||||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
|
|
||||||
</div>
|
|
||||||
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
|
||||||
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ seasonDetail.candidates.length }} Kandidaten
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="candidate in seasonDetail.candidates"
|
|
||||||
:key="candidate.id"
|
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
<Select
|
|
||||||
v-model="candidateForms[candidate.id].categoryId"
|
|
||||||
:options="categoryOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
|
||||||
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
|
||||||
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
|
|
||||||
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<Select
|
|
||||||
v-model="newCandidateForm.categoryId"
|
|
||||||
:options="categoryOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
|
||||||
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
|
||||||
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
|
||||||
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
|
|
||||||
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="activity in activities"
|
|
||||||
:key="activity.label"
|
|
||||||
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
|
||||||
>
|
|
||||||
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
|
|
||||||
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="p-7">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
{{ seasonDetail.pendingNominations.length }} offen
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="nomination in seasonDetail.pendingNominations"
|
|
||||||
:key="nomination.id"
|
|
||||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ nomination.categoryName }}</p>
|
|
||||||
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
|
||||||
ID {{ nomination.id }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
|
||||||
<input
|
|
||||||
v-model="reviewForms[nomination.id].displayName"
|
|
||||||
type="text"
|
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="Display Name"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="reviewForms[nomination.id].channelSlug"
|
|
||||||
type="text"
|
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="@channel"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="reviewForms[nomination.id].platform"
|
|
||||||
type="text"
|
|
||||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
|
||||||
placeholder="Platform"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
|
||||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
|
|
||||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
|
||||||
</Button>
|
|
||||||
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
|
|
||||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -55,10 +55,10 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
<Card class="min-h-[210px] p-7">
|
<Card class="min-h-[210px] p-7">
|
||||||
<div class="flex items-center gap-3 text-violet-600">
|
<div class="flex items-center gap-3 text-violet-600">
|
||||||
<Sparkles class="h-5 w-5 text-amber-500" />
|
<Sparkles class="h-5 w-5 text-amber-500" />
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Community powered</span>
|
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Von der Community getragen</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-5 text-sm leading-7 text-slate-600">
|
<p class="mt-5 text-sm leading-7 text-slate-600">
|
||||||
Twitch Login only, keine Konto-Huerde, editierbare Votes und Nominierungen bis zur Deadline.
|
Nur Twitch Login, keine Konto-Huerde, editierbare Stimmen und Nominierungen bis zur Deadline.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Team verwaltet pro Jahr</span>
|
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Team verwaltet pro Jahr</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-5 text-sm leading-7 text-slate-600">
|
<p class="mt-5 text-sm leading-7 text-slate-600">
|
||||||
Kategorien und Unterkategorien werden im Admin-Bereich je Season gepflegt und freigeschaltet.
|
Kategorien und Unterkategorien werden im Admin-Bereich je Jahr gepflegt und freigeschaltet.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,17 +81,17 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
{{ store.overview.currentPhase }}
|
{{ store.overview.currentPhase }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 max-w-md text-slate-600">
|
<p class="mt-2 max-w-md text-slate-600">
|
||||||
Login bleibt leichtgewichtig: Twitch only, kein separates Community-Konto.
|
Login bleibt leichtgewichtig: nur Twitch, kein separates Community-Konto.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700">
|
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Session Status</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Sitzungsstatus</p>
|
||||||
<p class="mt-2 font-semibold text-violet-800">
|
<p class="mt-2 font-semibold text-violet-800">
|
||||||
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
|
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 leading-7 text-slate-600">
|
<p class="mt-2 leading-7 text-slate-600">
|
||||||
{{ authStore.isLoggedIn ? 'Nominierung und Voting sind jetzt direkt freigeschaltet.' : 'Bitte oben im Header einloggen, um Nominierung, Voting oder Admin zu nutzen.' }}
|
{{ authStore.isLoggedIn ? 'Nominierung und Voting sind jetzt direkt freigeschaltet.' : 'Bitte oben im Kopfbereich einloggen, um Nominierung, Voting oder Admin zu nutzen.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
</div>
|
</div>
|
||||||
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p>
|
||||||
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Community only</p>
|
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Nur Community</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p>
|
||||||
@@ -202,9 +202,9 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
<WandSparkles class="h-5 w-5 text-amber-500" />
|
<WandSparkles class="h-5 w-5 text-amber-500" />
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Season-first Management</h2>
|
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Jahresbasiertes Management</h2>
|
||||||
<p class="mt-3 text-slate-600">
|
<p class="mt-3 text-slate-600">
|
||||||
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als saisonale Inhalte gedacht, nicht als harte statische App-Texte.
|
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als kuratierte Award-Jahre gedacht, nicht als harte statische App-Texte.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
@@ -245,7 +245,7 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
<ul class="mt-5 space-y-3 text-slate-600">
|
<ul class="mt-5 space-y-3 text-slate-600">
|
||||||
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</li>
|
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</li>
|
||||||
<li>Regeln werden direkt im Formular sichtbar gemacht.</li>
|
<li>Regeln werden direkt im Formular sichtbar gemacht.</li>
|
||||||
<li>Freitext-Ideen und Alias-Faelle gehen spaeter in die Review Queue.</li>
|
<li>Freitext-Ideen und Alias-Faelle gehen spaeter in die Review-Liste.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<RouterLink to="/nominations">
|
<RouterLink to="/nominations">
|
||||||
@@ -273,7 +273,7 @@ const heroYear = computed(() => store.overview.year)
|
|||||||
<section class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
<section class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
<Card class="p-6">
|
<Card class="p-6">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinner Archiv</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinner Archiv</p>
|
||||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Vergangene Seasons sichtbar machen</h2>
|
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Vergangene Jahre sichtbar machen</h2>
|
||||||
<p class="mt-4 text-slate-600">
|
<p class="mt-4 text-slate-600">
|
||||||
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
|
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async function submitNomination() {
|
|||||||
<ul class="mt-5 space-y-4 text-slate-600">
|
<ul class="mt-5 space-y-4 text-slate-600">
|
||||||
<li>Pro Kategorie nur eine Nominierung derselben Person.</li>
|
<li>Pro Kategorie nur eine Nominierung derselben Person.</li>
|
||||||
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li>
|
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li>
|
||||||
<li>Freitext-Ideen landen spaeter in der Review Queue.</li>
|
<li>Freitext-Ideen landen spaeter in der Review-Liste.</li>
|
||||||
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
|
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -91,7 +91,7 @@ async function submitNomination() {
|
|||||||
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
|
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
|
||||||
Bitte zuerst ueber den Header mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
|
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
|
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async function submitVote() {
|
|||||||
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
|
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
|
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
|
||||||
Bitte zuerst ueber den Header mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
|
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function selectYear(year: number) {
|
|||||||
<div class="space-y-10 pb-14">
|
<div class="space-y-10 pb-14">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p>
|
||||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Seasons, Gewinner und Show-Historie</h1>
|
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Jahre, Gewinner und Show-Historie</h1>
|
||||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||||
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
|
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BarChart3, Clock3, Sparkles, Tags, Users, Vote } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const totalVotes = computed(() => store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0)
|
||||||
|
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
|
||||||
|
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
|
||||||
|
const categoryHealth = computed(() =>
|
||||||
|
seasonDetail.value.categories
|
||||||
|
.map((category) => ({
|
||||||
|
name: category.name,
|
||||||
|
groupName: category.groupName,
|
||||||
|
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||||
|
reviews: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.candidates - a.candidates || b.reviews - a.reviews),
|
||||||
|
)
|
||||||
|
const metricCards = computed(() => [
|
||||||
|
{ label: 'Nominierungen', value: totalNominations.value, note: 'gesamt im Jahr', icon: Sparkles },
|
||||||
|
{ label: 'Stimmen', value: totalVotes.value, note: 'alle Votes', icon: Vote },
|
||||||
|
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'in allen Kategorien', icon: Users },
|
||||||
|
{ label: 'Reviews offen', value: seasonDetail.value.pendingNominations.length, note: 'Backlog', icon: Clock3 },
|
||||||
|
])
|
||||||
|
const insights = computed(() => {
|
||||||
|
const categoriesWithoutCandidates = categoryHealth.value.filter((category) => category.candidates === 0).length
|
||||||
|
const busiestReviewCategory = [...categoryHealth.value].sort((a, b) => b.reviews - a.reviews)[0]
|
||||||
|
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Votes pro Kandidat',
|
||||||
|
value: votesPerCandidate,
|
||||||
|
note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Leere Kategorien',
|
||||||
|
value: categoriesWithoutCandidates,
|
||||||
|
note: categoriesWithoutCandidates === 0 ? 'Alle Kategorien sind besetzt.' : 'Diese Kategorien brauchen Kandidatenpflege.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Review-Hotspot',
|
||||||
|
value: busiestReviewCategory?.reviews ?? 0,
|
||||||
|
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine Review-Daten vorhanden.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Analytics"
|
||||||
|
title="Zahlen, die Entscheidungen helfen"
|
||||||
|
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Luecken oder Backlog entstehen."
|
||||||
|
:icon="BarChart3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card v-for="metric in metricCards" :key="metric.label" class="p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ metric.label }}</p>
|
||||||
|
<strong class="mt-3 block text-3xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<component :is="metric.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Vote Performance</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top-Kategorien</h2>
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div v-for="category in store.admin.topCategories" :key="category.category" class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<p class="font-semibold text-slate-900">{{ category.category }}</p>
|
||||||
|
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 h-3 overflow-hidden rounded-full bg-[#f3ecff]">
|
||||||
|
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6,#f8d7a4)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie Health</p>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Abdeckung</h2>
|
||||||
|
</div>
|
||||||
|
<Tags class="h-6 w-6 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
|
||||||
|
<div v-for="category in categoryHealth" :key="category.name" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||||
|
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.reviews }} offene Reviews</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">
|
||||||
|
{{ category.candidates }} Kandidaten
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card v-for="insight in insights" :key="insight.label" class="p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ insight.label }}</p>
|
||||||
|
<strong class="mt-3 block text-3xl text-violet-900">{{ insight.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ insight.note }}</p>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import Select from 'primevue/select'
|
||||||
|
import { Search, Sparkles, Tags, UserPlus, Users } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const candidateSaving = ref<number | 'new' | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
|
||||||
|
const newCandidateForm = reactive({
|
||||||
|
categoryId: 0,
|
||||||
|
displayName: '',
|
||||||
|
channelSlug: '',
|
||||||
|
platform: 'Twitch',
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidateForms = reactive<Record<number, {
|
||||||
|
categoryId: number
|
||||||
|
displayName: string
|
||||||
|
channelSlug: string
|
||||||
|
platform: string
|
||||||
|
}>>({})
|
||||||
|
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const candidateFilter = ref('')
|
||||||
|
const categoryFilter = ref<number | null>(null)
|
||||||
|
const categoryOptions = computed(() =>
|
||||||
|
seasonDetail.value.categories.map((category) => ({
|
||||||
|
label: `${category.groupName} · ${category.name}`,
|
||||||
|
value: category.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const categoryFilterOptions = computed(() => [
|
||||||
|
{ label: 'Alle Kategorien', value: null },
|
||||||
|
...categoryOptions.value,
|
||||||
|
])
|
||||||
|
const categoryLabelMap = computed(() =>
|
||||||
|
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
|
||||||
|
)
|
||||||
|
const platformSummary = computed(() => {
|
||||||
|
const platforms = new Set(seasonDetail.value.candidates.map((candidate) => candidate.platform).filter(Boolean))
|
||||||
|
return platforms.size
|
||||||
|
})
|
||||||
|
const candidateStats = computed(() => [
|
||||||
|
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'im Jahr gepflegt' },
|
||||||
|
{ label: 'Kategorien', value: seasonDetail.value.categories.length, note: 'als Ziel verfuegbar' },
|
||||||
|
{ label: 'Plattformen', value: platformSummary.value, note: 'in der Kandidatenbasis' },
|
||||||
|
])
|
||||||
|
const filteredCandidates = computed(() => {
|
||||||
|
const query = candidateFilter.value.trim().toLowerCase()
|
||||||
|
const candidates = categoryFilter.value
|
||||||
|
? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === categoryFilter.value)
|
||||||
|
: seasonDetail.value.candidates
|
||||||
|
|
||||||
|
if (!query) return candidates
|
||||||
|
return candidates.filter((candidate) =>
|
||||||
|
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const hasFilters = computed(() => candidateFilter.value.trim().length > 0 || categoryFilter.value !== null)
|
||||||
|
const canCreateCandidate = computed(() =>
|
||||||
|
Boolean(selectedSeasonId.value && newCandidateForm.categoryId && newCandidateForm.displayName.trim() && newCandidateForm.channelSlug.trim()),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
for (const candidate of detail.candidates) {
|
||||||
|
candidateForms[candidate.id] = {
|
||||||
|
categoryId: candidate.categoryId,
|
||||||
|
displayName: candidate.displayName,
|
||||||
|
channelSlug: candidate.channelSlug,
|
||||||
|
platform: candidate.platform,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveCandidate(candidateId: number) {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
candidateSaving.value = candidateId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
||||||
|
adminMessage.value = 'Kandidat gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
candidateSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCandidate() {
|
||||||
|
if (!canCreateCandidate.value || !selectedSeasonId.value) return
|
||||||
|
|
||||||
|
candidateSaving.value = 'new'
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
||||||
|
adminMessage.value = 'Kandidat angelegt.'
|
||||||
|
newCandidateForm.displayName = ''
|
||||||
|
newCandidateForm.channelSlug = ''
|
||||||
|
newCandidateForm.platform = 'Twitch'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
||||||
|
} finally {
|
||||||
|
candidateSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
candidateFilter.value = ''
|
||||||
|
categoryFilter.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Kandidaten"
|
||||||
|
title="Kandidatenbasis pflegen"
|
||||||
|
description="Schneller finden, sauber pruefen, gezielt bearbeiten: die Kandidaten sind jetzt nach Jahr, Kategorie und Handle besser steuerbar."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
v-for="stat in candidateStats"
|
||||||
|
:key="stat.label"
|
||||||
|
class="p-5"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-2 block text-3xl text-violet-800">{{ stat.value }}</strong>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ stat.note }}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 bg-white/70 p-6">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kandidatenbereich</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Suchen, pruefen, aktualisieren</h2>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Filtere nach Kategorie oder Handle und bearbeite nur den Kandidaten, der wirklich geaendert werden muss.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
<strong class="text-violet-800">{{ filteredCandidates.length }}</strong> von {{ seasonDetail.candidates.length }} sichtbar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px_auto]">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input
|
||||||
|
v-model="candidateFilter"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Name, Handle, Plattform oder Kategorie suchen"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="categoryFilter"
|
||||||
|
:options="categoryFilterOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<article
|
||||||
|
v-for="candidate in filteredCandidates"
|
||||||
|
:key="candidate.id"
|
||||||
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">
|
||||||
|
{{ candidate.platform }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-700">
|
||||||
|
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-3 truncate font-[Cormorant_Garamond] text-4xl text-violet-800">{{ candidate.displayName }}</h3>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-slate-500">{{ candidate.channelSlug }}</p>
|
||||||
|
</div>
|
||||||
|
<Button :disabled="candidateSaving === candidate.id" size="sm" @click="saveCandidate(candidate.id)">
|
||||||
|
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 md:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
||||||
|
<Select
|
||||||
|
v-model="candidateForms[candidate.id].categoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||||
|
<input v-model="candidateForms[candidate.id].displayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Anzeigename" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||||
|
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||||
|
<input v-model="candidateForms[candidate.id].platform" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube, ..." />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p v-if="filteredCandidates.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Kandidaten passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<aside class="space-y-4 xl:sticky xl:top-6 xl:self-start">
|
||||||
|
<Card class="p-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="rounded-2xl bg-violet-100 p-3 text-violet-700">
|
||||||
|
<UserPlus class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
|
||||||
|
<h2 class="mt-1 font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidat anlegen</h2>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">Erstelle bekannte Kandidaten direkt fuer die richtige Kategorie.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
||||||
|
<Select
|
||||||
|
v-model="newCandidateForm.categoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||||
|
<input v-model="newCandidateForm.displayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. Jayuhime" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||||
|
<input v-model="newCandidateForm.channelSlug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||||
|
<input v-model="newCandidateForm.platform" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
|
||||||
|
</label>
|
||||||
|
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate">
|
||||||
|
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||||
|
{{ adminMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{{ adminError }}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Sparkles class="h-5 w-5 text-amber-500" />
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ablauf</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||||
|
<p class="flex gap-3"><Users class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Erst Kandidaten suchen, damit du keine Duplikate anlegst.</p>
|
||||||
|
<p class="flex gap-3"><Tags class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Kategorie-Chip pruefen, dann nur die noetigen Felder anpassen.</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { Layers3, PlusCircle, Search, Tags } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const query = ref('')
|
||||||
|
const statusFilter = ref<'all' | 'empty' | 'reviews' | 'thin'>('all')
|
||||||
|
const selectedCategoryId = ref<number | null>(null)
|
||||||
|
const saving = ref<number | 'new' | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
|
||||||
|
const editForms = reactive<Record<number, {
|
||||||
|
groupName: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
sortOrder: number
|
||||||
|
maxNomineesPerUser: number
|
||||||
|
}>>({})
|
||||||
|
const newCategoryForm = reactive({
|
||||||
|
groupName: '',
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
sortOrder: 1,
|
||||||
|
maxNomineesPerUser: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoriesWithState = computed(() =>
|
||||||
|
seasonDetail.value.categories
|
||||||
|
.map((category) => ({
|
||||||
|
...category,
|
||||||
|
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||||
|
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
)
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
const search = query.value.trim().toLowerCase()
|
||||||
|
return categoriesWithState.value.filter((category) => {
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter.value === 'all' ||
|
||||||
|
(statusFilter.value === 'empty' && category.candidates === 0) ||
|
||||||
|
(statusFilter.value === 'reviews' && category.pending > 0) ||
|
||||||
|
(statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser))
|
||||||
|
const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)
|
||||||
|
return matchesStatus && matchesSearch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const selectedCategory = computed(() =>
|
||||||
|
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
|
||||||
|
)
|
||||||
|
const categoryStats = computed(() => [
|
||||||
|
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
|
||||||
|
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
|
||||||
|
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
|
||||||
|
])
|
||||||
|
const statusFilters = computed(() => [
|
||||||
|
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
|
||||||
|
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
|
||||||
|
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
|
||||||
|
{ key: 'thin' as const, label: 'Duenn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
|
||||||
|
])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
for (const category of detail.categories) {
|
||||||
|
editForms[category.id] = {
|
||||||
|
groupName: category.groupName,
|
||||||
|
name: category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
description: category.description,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newCategoryForm.sortOrder = detail.categories.length + 1
|
||||||
|
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||||
|
selectedCategoryId.value = detail.categories[0]?.id ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(filteredCategories, (categories) => {
|
||||||
|
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||||
|
selectedCategoryId.value = categories[0]?.id ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveCategory(categoryId: number) {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
saving.value = categoryId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
try {
|
||||||
|
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
||||||
|
adminMessage.value = 'Kategorie gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
saving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCategory() {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
saving.value = 'new'
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
try {
|
||||||
|
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||||
|
adminMessage.value = 'Kategorie angelegt.'
|
||||||
|
newCategoryForm.groupName = ''
|
||||||
|
newCategoryForm.name = ''
|
||||||
|
newCategoryForm.slug = ''
|
||||||
|
newCategoryForm.description = ''
|
||||||
|
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
|
||||||
|
newCategoryForm.maxNomineesPerUser = 3
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||||
|
} finally {
|
||||||
|
saving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillNewSlug() {
|
||||||
|
newCategoryForm.slug = slugify(newCategoryForm.name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Kategorien"
|
||||||
|
title="Award-Struktur pflegen"
|
||||||
|
description="Eine kompakte Arbeitsansicht fuer viele Kategorien: links filtern und auswaehlen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
|
||||||
|
:icon="Tags"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[minmax(320px,0.82fr)_minmax(0,1.18fr)]">
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie, Gruppe oder Slug suchen" />
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||||
|
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||||
|
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="filter in statusFilters"
|
||||||
|
:key="filter.key"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||||
|
@click="statusFilter = filter.key"
|
||||||
|
>
|
||||||
|
{{ filter.label }} · {{ filter.count }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[700px] space-y-2 overflow-y-auto p-4">
|
||||||
|
<button
|
||||||
|
v-for="category in filteredCategories"
|
||||||
|
:key="category.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-2xl border p-3 text-left transition"
|
||||||
|
:class="selectedCategory?.id === category.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:bg-violet-50/50'"
|
||||||
|
@click="selectedCategoryId = category.id"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||||
|
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · /{{ category.slug }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">{{ category.candidates }} Kandidaten</span>
|
||||||
|
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">Limit {{ category.maxNomineesPerUser }}</span>
|
||||||
|
<span v-if="category.pending" class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">{{ category.pending }} Reviews</span>
|
||||||
|
<span v-if="category.candidates === 0" class="rounded-full bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">leer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">#{{ category.sortOrder }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card v-if="selectedCategory" class="p-6">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie bearbeiten</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<Layers3 class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||||
|
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].groupName" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].name" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].slug" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].sortOrder" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].maxNomineesPerUser" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-4 block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||||
|
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 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" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
|
||||||
|
{{ saving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<PlusCircle class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
|
||||||
|
<h2 class="mt-1 font-[Cormorant_Garamond] text-3xl text-violet-800">Kategorie anlegen</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<input v-model="newCategoryForm.groupName" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
|
||||||
|
<input v-model="newCategoryForm.name" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input v-model="newCategoryForm.slug" class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
|
||||||
|
<button type="button" class="h-12 rounded-2xl border border-violet-100 bg-violet-50 px-4 text-xs font-semibold text-violet-700 transition hover:bg-violet-100" @click="fillNewSlug">
|
||||||
|
Auto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Reihenfolge" />
|
||||||
|
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Limit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea v-model="newCategoryForm.description" class="mt-4 min-h-20 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Beschreibung" />
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<Button :disabled="saving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||||
|
{{ saving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ExternalLink, Film, Search, Tags, Users } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const query = ref('')
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const clipCategories = computed(() =>
|
||||||
|
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')),
|
||||||
|
)
|
||||||
|
const clipCategoryIds = computed(() => new Set(clipCategories.value.map((category) => category.id)))
|
||||||
|
const clipCandidates = computed(() => {
|
||||||
|
const search = query.value.trim().toLowerCase()
|
||||||
|
return seasonDetail.value.candidates
|
||||||
|
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId))
|
||||||
|
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
|
||||||
|
})
|
||||||
|
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
|
||||||
|
const clipReviewCount = computed(() => seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length)
|
||||||
|
const clipReadiness = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Clip-Kategorie existiert',
|
||||||
|
done: clipCategories.value.length > 0,
|
||||||
|
note: clipCategories.value.length > 0 ? `${clipCategories.value.length} Clip-Kategorien gefunden.` : 'Lege mindestens eine Clip-Kategorie an.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kandidaten vorhanden',
|
||||||
|
done: clipCandidates.value.length > 0,
|
||||||
|
note: clipCandidates.value.length > 0 ? `${clipCandidates.value.length} Clip-Kandidaten gepflegt.` : 'Noch keine Clip-Kandidaten vorhanden.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Review-Restbestand',
|
||||||
|
done: clipReviewCount.value === 0,
|
||||||
|
note: clipReviewCount.value === 0 ? 'Keine offenen Clip-Reviews.' : `${clipReviewCount.value} Clip-Reviews offen.`,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
|
||||||
|
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
|
||||||
|
{ label: 'Offene Reviews', value: clipReviewCount.value, icon: Film },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Clips"
|
||||||
|
title="Clip-Kategorien im Blick behalten"
|
||||||
|
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an."
|
||||||
|
:icon="Film"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card v-for="stat in stats" :key="stat.label" class="p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-3 block text-3xl text-violet-900">{{ stat.value }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<component :is="stat.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2>
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="item in clipReadiness"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-2xl border p-4"
|
||||||
|
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'"
|
||||||
|
>
|
||||||
|
<p class="font-semibold text-slate-900">{{ item.label }}</p>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">{{ item.note }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
|
||||||
|
<p class="font-semibold text-amber-800">Naechster Backend-Ausbau</p>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Clip-Kandidat, Handle oder Plattform suchen" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-violet-50">
|
||||||
|
<div v-for="candidate in clipCandidates" :key="candidate.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-semibold text-slate-900">{{ candidate.displayName }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }} · {{ candidateCategory[candidate.categoryId] }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||||
|
<ExternalLink class="h-3.5 w-3.5" />
|
||||||
|
Clip-Link spaeter
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||||
|
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ArrowDownRight, ArrowUpRight, BarChart3, Clock3, LayoutDashboard, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
|
||||||
|
const metrics = computed(() => store.admin.metrics)
|
||||||
|
const activities = computed(() => store.admin.activities)
|
||||||
|
const topCategories = computed(() => store.admin.topCategories)
|
||||||
|
const metricToneMap = {
|
||||||
|
Nominierungen: {
|
||||||
|
icon: Sparkles,
|
||||||
|
trend: 12.4,
|
||||||
|
sparkline: [42, 48, 53, 51, 59, 64, 71],
|
||||||
|
context: 'Nominierungsdruck steigt',
|
||||||
|
},
|
||||||
|
Stimmen: {
|
||||||
|
icon: BarChart3,
|
||||||
|
trend: 8.7,
|
||||||
|
sparkline: [54, 57, 63, 66, 72, 76, 81],
|
||||||
|
context: 'Voting-Aktivitaet stabil positiv',
|
||||||
|
},
|
||||||
|
Kategorien: {
|
||||||
|
icon: Tags,
|
||||||
|
trend: 0,
|
||||||
|
sparkline: [62, 62, 62, 63, 63, 63, 63],
|
||||||
|
context: 'Struktur bleibt konstant',
|
||||||
|
},
|
||||||
|
'Reviews offen': {
|
||||||
|
icon: Clock3,
|
||||||
|
trend: -6.2,
|
||||||
|
sparkline: [82, 78, 75, 73, 68, 65, 61],
|
||||||
|
context: 'Backlog wird kleiner',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const metricCards = computed(() =>
|
||||||
|
metrics.value.map((metric) => ({
|
||||||
|
...metric,
|
||||||
|
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
|
||||||
|
icon: BarChart3,
|
||||||
|
trend: 0,
|
||||||
|
sparkline: [50, 50, 50, 50, 50, 50, 50],
|
||||||
|
context: metric.note,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
||||||
|
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
|
||||||
|
const yearTotals = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Nominierungen gesamt',
|
||||||
|
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
|
||||||
|
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
|
||||||
|
icon: Sparkles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stimmen gesamt',
|
||||||
|
value: metrics.value.find((metric) => metric.label === 'Stimmen')?.value ?? 0,
|
||||||
|
note: 'alle abgegebenen Votes',
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kandidaten',
|
||||||
|
value: store.adminSeasonDetail.candidates.length,
|
||||||
|
note: 'fuer Voting und Archiv gepflegt',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kategorien',
|
||||||
|
value: store.adminSeasonDetail.categories.length,
|
||||||
|
note: 'aktive Award-Kategorien',
|
||||||
|
icon: Tags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Offene Reviews',
|
||||||
|
value: store.adminSeasonDetail.pendingNominations.length,
|
||||||
|
note: 'brauchen Team-Entscheidung',
|
||||||
|
icon: Clock3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Risikohinweise',
|
||||||
|
value: store.admin.riskFlags.length,
|
||||||
|
note: 'aktuell offen',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const priorityActions = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Reviews bearbeiten',
|
||||||
|
value: store.adminSeasonDetail.pendingNominations.length,
|
||||||
|
to: '/admin/reviews',
|
||||||
|
hint: 'Freitext-Nominierungen warten auf Entscheidung',
|
||||||
|
icon: Sparkles,
|
||||||
|
tone: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Risiko pruefen',
|
||||||
|
value: store.admin.riskFlags.length,
|
||||||
|
to: '/admin/risk',
|
||||||
|
hint: 'Auffaellige Muster brauchen Sichtung',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
tone: 'rose',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kategorien pflegen',
|
||||||
|
value: store.adminSeasonDetail.categories.length,
|
||||||
|
to: '/admin/categories',
|
||||||
|
hint: 'Texte, Limits und Reihenfolge aktuell halten',
|
||||||
|
icon: Tags,
|
||||||
|
tone: 'amber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kandidatenbasis',
|
||||||
|
value: store.adminSeasonDetail.candidates.length,
|
||||||
|
to: '/admin/candidates',
|
||||||
|
hint: 'Kandidaten und Plattformen schnell pruefen',
|
||||||
|
icon: Users,
|
||||||
|
tone: 'emerald',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const operationChecks = computed(() => {
|
||||||
|
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
|
||||||
|
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||||
|
)
|
||||||
|
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
|
||||||
|
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Kategorien ohne Kandidaten',
|
||||||
|
value: categoriesWithoutCandidates.length,
|
||||||
|
to: '/admin/categories',
|
||||||
|
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
|
||||||
|
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt pruefen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Review-Backlog verteilt',
|
||||||
|
value: categoriesWithReviews.length,
|
||||||
|
to: '/admin/nominations',
|
||||||
|
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
|
||||||
|
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Risk Flags offen',
|
||||||
|
value: store.admin.riskFlags.length,
|
||||||
|
to: '/admin/risk',
|
||||||
|
state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger',
|
||||||
|
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst pruefen.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Dashboard"
|
||||||
|
title="Was braucht gerade Aufmerksamkeit?"
|
||||||
|
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebuendelt, damit du schneller entscheiden kannst, was als Naechstes drankommt."
|
||||||
|
:icon="LayoutDashboard"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2>
|
||||||
|
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
Voting und Nominierungen ziehen an, waehrend der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||||
|
+9.8% Gesamtaktivitaet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 p-5 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="metric in metricCards"
|
||||||
|
:key="metric.label"
|
||||||
|
class="rounded-[24px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ metric.label }}</p>
|
||||||
|
<strong class="mt-3 block text-4xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<component :is="metric.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="metric.trend < 0 ? 'bg-emerald-50 text-emerald-700' : metric.trend > 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'"
|
||||||
|
>
|
||||||
|
<ArrowDownRight v-if="metric.trend < 0" class="h-3.5 w-3.5" />
|
||||||
|
<ArrowUpRight v-else-if="metric.trend > 0" class="h-3.5 w-3.5" />
|
||||||
|
{{ metric.trend === 0 ? 'stabil' : `${metric.trend > 0 ? '+' : ''}${metric.trend}%` }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ metric.context }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex h-16 items-end gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="(value, index) in metric.sparkline"
|
||||||
|
:key="`${metric.label}-${index}`"
|
||||||
|
class="flex-1 rounded-t-full bg-gradient-to-t from-[#7c5cff] to-[#c4b5fd]"
|
||||||
|
:style="{ height: `${value}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-6">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Schnellzugriffe</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was zuerst?</h2>
|
||||||
|
</div>
|
||||||
|
<Clock3 class="h-6 w-6 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in priorityActions"
|
||||||
|
:key="item.label"
|
||||||
|
:to="item.to"
|
||||||
|
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
|
||||||
|
:class="{
|
||||||
|
'bg-violet-100 text-violet-700': item.tone === 'violet',
|
||||||
|
'bg-rose-100 text-rose-700': item.tone === 'rose',
|
||||||
|
'bg-amber-100 text-amber-700': item.tone === 'amber',
|
||||||
|
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-800">{{ item.label }}</p>
|
||||||
|
<p class="truncate text-sm text-slate-500">{{ item.hint }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<strong class="rounded-full border border-violet-100 bg-white px-3 py-1 text-violet-800">{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<RouterLink
|
||||||
|
v-for="check in operationChecks"
|
||||||
|
:key="check.label"
|
||||||
|
:to="check.to"
|
||||||
|
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
|
||||||
|
:class="{
|
||||||
|
'border-emerald-100': check.state === 'ok',
|
||||||
|
'border-amber-100': check.state === 'warn',
|
||||||
|
'border-rose-100': check.state === 'danger',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
|
||||||
|
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
|
||||||
|
{{ check.value }}
|
||||||
|
</strong>
|
||||||
|
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Jahreszahlen</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Gesamtmetriken {{ store.adminSeasonDetail.year }}</h2>
|
||||||
|
</div>
|
||||||
|
<BarChart3 class="h-6 w-6 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="item in yearTotals"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-[22px] border border-violet-100 bg-white/90 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.label }}</p>
|
||||||
|
<strong class="mt-2 block text-3xl text-violet-900">{{ item.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<component :is="item.icon" class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-5 text-slate-500">{{ item.note }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Stimmen</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="(category, index) in topCategories"
|
||||||
|
:key="category.category"
|
||||||
|
class="rounded-[24px] border border-violet-100 bg-white/90 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">#{{ index + 1 }}</p>
|
||||||
|
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
|
||||||
|
</div>
|
||||||
|
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
|
||||||
|
<div
|
||||||
|
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
|
||||||
|
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitaeten</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert fuer den schnellen Blick.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="activity in activities"
|
||||||
|
:key="activity.label"
|
||||||
|
class="rounded-[24px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||||
|
>
|
||||||
|
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Noch keine aktuellen Audit-Aktivitaeten vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BarChart3,
|
||||||
|
CalendarCog,
|
||||||
|
ClipboardList,
|
||||||
|
Film,
|
||||||
|
LayoutDashboard,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Tags,
|
||||||
|
UserCog,
|
||||||
|
Users,
|
||||||
|
Vote,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const navGroups = [
|
||||||
|
{
|
||||||
|
label: 'Betrieb',
|
||||||
|
items: [
|
||||||
|
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null },
|
||||||
|
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||||
|
{ label: 'Voting', to: '/admin/voting', description: 'Stimmen und Readiness', icon: Vote, badge: () => `${store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Inhalte',
|
||||||
|
items: [
|
||||||
|
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
|
||||||
|
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
|
||||||
|
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
|
||||||
|
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien pruefen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kontrolle',
|
||||||
|
items: [
|
||||||
|
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Faelle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||||
|
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags pruefen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
|
||||||
|
{ label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Auswertung',
|
||||||
|
items: [
|
||||||
|
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => `${store.admin.topCategories.length}` },
|
||||||
|
{ label: 'Einstellungen', to: '/admin/settings', description: 'Public-Status und Checks', icon: Settings, badge: () => null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentSeason = computed(() => store.adminSeasonDetail)
|
||||||
|
const seasonSummary = computed(() => [
|
||||||
|
{ label: 'Kategorien', value: currentSeason.value.categories.length },
|
||||||
|
{ label: 'Kandidaten', value: currentSeason.value.candidates.length },
|
||||||
|
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length },
|
||||||
|
])
|
||||||
|
|
||||||
|
function isActive(to: string) {
|
||||||
|
return route.path === to
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!authStore.isAdmin) return
|
||||||
|
await store.initializeAdminWorkspace()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pb-10">
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[292px_minmax(0,1fr)]">
|
||||||
|
<aside class="space-y-3 xl:sticky xl:top-4 xl:h-fit">
|
||||||
|
<Card class="p-3">
|
||||||
|
<nav class="space-y-4">
|
||||||
|
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
|
||||||
|
<p class="px-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">{{ group.label }}</p>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="group block rounded-2xl border px-3 py-2.5 transition"
|
||||||
|
:class="isActive(item.to) ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
|
||||||
|
:class="isActive(item.to) ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="h-4.5 w-4.5" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex min-w-0 items-center justify-between gap-3">
|
||||||
|
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
|
||||||
|
<span
|
||||||
|
v-if="item.badge()"
|
||||||
|
class="grid h-6 min-w-6 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-[11px] font-semibold text-violet-700 shadow-sm"
|
||||||
|
>
|
||||||
|
{{ item.badge() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 truncate text-xs leading-5 text-slate-500">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
</nav>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-3">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p>
|
||||||
|
<p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswaehlen.' }}</p>
|
||||||
|
<div class="mt-3 grid grid-cols-3 gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="item in seasonSummary"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-md border border-white/80 bg-white/75 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<p class="truncate text-[9px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ item.label }}</p>
|
||||||
|
<strong class="block text-base leading-5 text-violet-800">{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ClipboardList, Search, Sparkles, Tags, Users } from '@lucide/vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const query = ref('')
|
||||||
|
const categoryFilter = ref<number | null>(null)
|
||||||
|
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
|
||||||
|
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
|
||||||
|
const filteredNominations = computed(() => {
|
||||||
|
const search = query.value.trim().toLowerCase()
|
||||||
|
const heavyCategoryIds = new Set(categoryStats.value.filter((category) => category.pending >= 3).map((category) => category.id))
|
||||||
|
return seasonDetail.value.pendingNominations.filter((nomination) => {
|
||||||
|
const matchesCategory = !categoryFilter.value || nomination.categoryId === categoryFilter.value
|
||||||
|
const category = categoryMap.value[nomination.categoryId]
|
||||||
|
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === nomination.categoryId).length
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter.value === 'all' ||
|
||||||
|
(statusFilter.value === 'selected' && !!categoryFilter.value) ||
|
||||||
|
(statusFilter.value === 'empty-category' && candidateCount === 0) ||
|
||||||
|
(statusFilter.value === 'heavy' && heavyCategoryIds.has(nomination.categoryId))
|
||||||
|
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)
|
||||||
|
return matchesCategory && matchesStatus && matchesSearch && !!category
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const categoryStats = computed(() =>
|
||||||
|
seasonDetail.value.categories
|
||||||
|
.map((category) => ({
|
||||||
|
...category,
|
||||||
|
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||||
|
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.pending - a.pending || a.sortOrder - b.sortOrder),
|
||||||
|
)
|
||||||
|
const nominationStats = computed(() => [
|
||||||
|
{ label: 'Offene Nominierungen', value: seasonDetail.value.pendingNominations.length, icon: Sparkles },
|
||||||
|
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
|
||||||
|
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
|
||||||
|
])
|
||||||
|
const statusFilters = computed(() => [
|
||||||
|
{ key: 'all' as const, label: 'Alle', count: seasonDetail.value.pendingNominations.length },
|
||||||
|
{ key: 'empty-category' as const, label: 'Ohne Kandidatenbasis', count: seasonDetail.value.pendingNominations.filter((nomination) => !seasonDetail.value.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)).length },
|
||||||
|
{ key: 'heavy' as const, label: 'Hoher Druck', count: categoryStats.value.filter((category) => category.pending >= 3).reduce((sum, category) => sum + category.pending, 0) },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Nominierungen"
|
||||||
|
title="Eingang und Backlog verstehen"
|
||||||
|
description="Hier siehst du, wo Freitext-Nominierungen auflaufen. Die eigentliche Entscheidung bleibt im Review-Bereich, aber diese Ansicht zeigt dir schneller, welche Kategorien Aufmerksamkeit brauchen."
|
||||||
|
:icon="ClipboardList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card v-for="stat in nominationStats" :key="stat.label" class="p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-3 block text-4xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<component :is="stat.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Wo staut es sich?</h2>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-violet-50">
|
||||||
|
<button
|
||||||
|
v-for="category in categoryStats"
|
||||||
|
:key="category.id"
|
||||||
|
type="button"
|
||||||
|
class="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4 text-left transition hover:bg-violet-50/50"
|
||||||
|
:class="categoryFilter === category.id ? 'bg-violet-50/80' : ''"
|
||||||
|
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
|
||||||
|
>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate font-semibold text-slate-900">{{ category.name }}</span>
|
||||||
|
<span class="mt-1 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Nach Kandidat, User oder Kategorie suchen"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<RouterLink to="/admin/reviews" class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500">
|
||||||
|
Reviews öffnen
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="filter in statusFilters"
|
||||||
|
:key="filter.key"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||||
|
@click="statusFilter = filter.key"
|
||||||
|
>
|
||||||
|
{{ filter.label }} · {{ filter.count }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
|
||||||
|
<div v-for="nomination in filteredNominations" :key="nomination.id" class="px-5 py-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||||
|
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||||
|
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
|
||||||
|
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>
|
||||||
|
erst Kandidatenbasis klaeren
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||||
|
Keine Nominierungen passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { CheckCircle2, Search, Sparkles, Trash2 } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const reviewSaving = ref<number | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
|
||||||
|
const reviewForms = reactive<Record<number, {
|
||||||
|
displayName: string
|
||||||
|
channelSlug: string
|
||||||
|
platform: string
|
||||||
|
}>>({})
|
||||||
|
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const reviewFilter = ref('')
|
||||||
|
const categoryFilter = ref<number | null>(null)
|
||||||
|
const selectedNominationId = ref<number | null>(null)
|
||||||
|
const filteredNominations = computed(() => {
|
||||||
|
const query = reviewFilter.value.trim().toLowerCase()
|
||||||
|
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||||
|
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
|
||||||
|
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const selectedNomination = computed(() =>
|
||||||
|
filteredNominations.value.find((nomination) => nomination.id === selectedNominationId.value) ?? filteredNominations.value[0] ?? null,
|
||||||
|
)
|
||||||
|
const reviewStats = computed(() => [
|
||||||
|
{ label: 'Offen', value: seasonDetail.value.pendingNominations.length },
|
||||||
|
{ label: 'Sichtbar', value: filteredNominations.value.length },
|
||||||
|
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
|
||||||
|
])
|
||||||
|
const categoryOptions = computed(() =>
|
||||||
|
seasonDetail.value.categories
|
||||||
|
.filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
|
||||||
|
.map((category) => ({
|
||||||
|
id: category.id,
|
||||||
|
label: category.name,
|
||||||
|
count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const selectedCandidateCollision = computed(() => {
|
||||||
|
if (!selectedNomination.value) return null
|
||||||
|
const form = reviewForms[selectedNomination.value.id]
|
||||||
|
if (!form) return null
|
||||||
|
const normalizedName = form.displayName.trim().toLowerCase()
|
||||||
|
const normalizedSlug = form.channelSlug.trim().toLowerCase()
|
||||||
|
return seasonDetail.value.candidates.find((candidate) =>
|
||||||
|
candidate.categoryId === selectedNomination.value?.categoryId &&
|
||||||
|
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
|
||||||
|
) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
for (const nomination of detail.pendingNominations) {
|
||||||
|
reviewForms[nomination.id] = {
|
||||||
|
displayName: nomination.candidateText,
|
||||||
|
channelSlug: '',
|
||||||
|
platform: 'Twitch',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||||
|
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filteredNominations,
|
||||||
|
(nominations) => {
|
||||||
|
if (!nominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||||
|
selectedNominationId.value = nominations[0]?.id ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function approveNomination(nominationId: number) {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
reviewSaving.value = nominationId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
||||||
|
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
|
||||||
|
} finally {
|
||||||
|
reviewSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectNomination(nominationId: number) {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
reviewSaving.value = nominationId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
|
||||||
|
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||||
|
} finally {
|
||||||
|
reviewSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlatform(platform: string) {
|
||||||
|
if (!selectedNomination.value) return
|
||||||
|
reviewForms[selectedNomination.value.id].platform = platform
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Reviews"
|
||||||
|
title="Freitext-Nominierungen sichten"
|
||||||
|
description="Alle uneindeutigen oder noch nicht gemappten Nominierungen laufen hier zusammen. Suche nach User, Kategorie oder Kandidat und entscheide dann direkt im Kontext."
|
||||||
|
:icon="Sparkles"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 bg-white/75 p-6">
|
||||||
|
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Kompakte Liste fuer viele Freitext-Faelle. Waehle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
|
||||||
|
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input
|
||||||
|
v-model="reviewFilter"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Nach Kategorie, Kandidat oder Nutzer suchen"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||||
|
@click="categoryFilter = null"
|
||||||
|
>
|
||||||
|
Alle Kategorien
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="category in categoryOptions"
|
||||||
|
:key="category.id"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||||
|
@click="categoryFilter = category.id"
|
||||||
|
>
|
||||||
|
{{ category.label }} · {{ category.count }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 p-6">
|
||||||
|
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||||
|
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||||
|
|
||||||
|
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
|
||||||
|
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||||
|
<button
|
||||||
|
v-for="nomination in filteredNominations"
|
||||||
|
:key="nomination.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-2xl border p-3 text-left transition"
|
||||||
|
:class="selectedNomination?.id === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||||
|
@click="selectedNominationId = nomination.id"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
|
||||||
|
{{ nomination.categoryName }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
||||||
|
ID {{ nomination.id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||||
|
<p class="mt-1 truncate text-sm text-slate-500">
|
||||||
|
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine offenen Review-Faelle im aktuell gewaehlten Award-Jahr.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Review-Faelle passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedNomination" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
|
||||||
|
<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.22em] text-violet-500">{{ selectedNomination.categoryName }}</p>
|
||||||
|
<h3 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedNomination.candidateText }}</h3>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
Eingereicht von {{ selectedNomination.submittedByTwitchId }} · {{ new Date(selectedNomination.createdAt).toLocaleString('de-DE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
||||||
|
ID {{ selectedNomination.id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat uebernehmen</p>
|
||||||
|
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||||
|
<input
|
||||||
|
v-model="reviewForms[selectedNomination.id].displayName"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Anzeigename"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||||
|
<input
|
||||||
|
v-model="reviewForms[selectedNomination.id].channelSlug"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="@channel"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||||
|
<input
|
||||||
|
v-model="reviewForms[selectedNomination.id].platform"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Twitch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="platform in ['Twitch', 'YouTube', 'TikTok']"
|
||||||
|
:key="platform"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="reviewForms[selectedNomination.id].platform === platform ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-white/70 text-slate-600 hover:bg-white'"
|
||||||
|
@click="setPlatform(platform)"
|
||||||
|
>
|
||||||
|
{{ platform }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||||
|
<Button :disabled="reviewSaving === selectedNomination.id" variant="secondary" @click="rejectNomination(selectedNomination.id)">
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)">
|
||||||
|
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||||
|
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Search, ShieldAlert } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const riskSaving = ref<number | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
const riskFilter = ref('')
|
||||||
|
const auditFilter = ref('')
|
||||||
|
const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
|
||||||
|
|
||||||
|
const riskFlags = computed(() => store.admin.riskFlags)
|
||||||
|
const auditEntries = computed(() => store.admin.auditEntries)
|
||||||
|
const filteredRiskFlags = computed(() => {
|
||||||
|
const query = riskFilter.value.trim().toLowerCase()
|
||||||
|
return riskFlags.value.filter((flag) =>
|
||||||
|
(severityFilter.value === 'all' || flag.severity.toLowerCase() === severityFilter.value) &&
|
||||||
|
(!query || [flag.source, flag.type, flag.summary, flag.twitchUserId ?? '', flag.createdFromIp]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const filteredAuditEntries = computed(() => {
|
||||||
|
const query = auditFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return auditEntries.value
|
||||||
|
return auditEntries.value.filter((entry) =>
|
||||||
|
[entry.summary, entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const riskStats = computed(() => [
|
||||||
|
{ label: 'Offen', value: riskFlags.value.length },
|
||||||
|
{ label: 'High', value: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
|
||||||
|
{ label: 'User betroffen', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
|
||||||
|
])
|
||||||
|
const severityFilters = computed(() => [
|
||||||
|
{ key: 'all' as const, label: 'Alle', count: riskFlags.value.length },
|
||||||
|
{ key: 'high' as const, label: 'High', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
|
||||||
|
{ key: 'medium' as const, label: 'Medium', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'medium').length },
|
||||||
|
{ key: 'low' as const, label: 'Low', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'low').length },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||||
|
riskSaving.value = riskFlagId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.resolveRiskFlag(riskFlagId, status)
|
||||||
|
adminMessage.value = `Risikohinweis ${riskFlagId} wurde aktualisiert.`
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Risikohinweis konnte nicht aktualisiert werden.'
|
||||||
|
} finally {
|
||||||
|
riskSaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Risiko & Audit"
|
||||||
|
title="Auffaellige Muster und Admin-Aktionen verfolgen"
|
||||||
|
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgefuehrte Eingriffe deutlich schneller."
|
||||||
|
:icon="ShieldAlert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikopruefung</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 grid gap-2 sm:grid-cols-3">
|
||||||
|
<div v-for="stat in riskStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||||
|
{{ adminMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{{ adminError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input
|
||||||
|
v-model="riskFilter"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Typ, Nutzer oder IP filtern"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprueften Fall.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="filter in severityFilters"
|
||||||
|
:key="filter.key"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||||
|
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||||
|
@click="severityFilter = filter.key"
|
||||||
|
>
|
||||||
|
{{ filter.label }} · {{ filter.count }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="flag in filteredRiskFlags"
|
||||||
|
:key="flag.id"
|
||||||
|
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ flag.source }} · {{ flag.type }}</p>
|
||||||
|
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ flag.summary }}</h3>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
{{ flag.twitchUserId || 'unbekannter User' }} · {{ flag.createdFromIp }} · {{ new Date(flag.createdAt).toLocaleString('de-DE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-slate-600">
|
||||||
|
{{ flag.severity }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre class="mt-4 overflow-x-auto rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-xs text-slate-600">{{ flag.metadataJson }}</pre>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||||
|
<Button :disabled="riskSaving === flag.id" variant="secondary" @click="resolveRiskFlag(flag.id, 'dismissed')">
|
||||||
|
{{ riskSaving === flag.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
|
||||||
|
{{ riskSaving === flag.id ? 'Speichert ...' : 'Erledigt markieren' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine offenen Risikohinweise vorhanden.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="filteredRiskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Risikohinweise passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-7">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Eintraege
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<input
|
||||||
|
v-model="auditFilter"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||||
|
placeholder="Audit-Eintraege nach Aktion, Admin oder Objekt filtern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="entry in filteredAuditEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-800">{{ entry.summary }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Noch keine Audit-Eintraege vorhanden.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Audit-Eintraege passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { CalendarCog, Layers3, PlusCircle, Search } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const seasonSaving = ref(false)
|
||||||
|
const categorySaving = ref<number | 'new' | null>(null)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
|
||||||
|
const seasonForm = reactive({
|
||||||
|
currentPhase: '',
|
||||||
|
isCurrent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newCategoryForm = reactive({
|
||||||
|
groupName: '',
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
sortOrder: 1,
|
||||||
|
maxNomineesPerUser: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const editForms = reactive<Record<number, {
|
||||||
|
groupName: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
sortOrder: number
|
||||||
|
maxNomineesPerUser: number
|
||||||
|
}>>({})
|
||||||
|
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const categoryFilter = ref('')
|
||||||
|
const selectedCategoryId = ref<number | null>(null)
|
||||||
|
const categoryStats = computed(() => [
|
||||||
|
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
|
||||||
|
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
|
||||||
|
{ label: 'Reviews offen', value: seasonDetail.value.pendingNominations.length },
|
||||||
|
])
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
const query = categoryFilter.value.trim().toLowerCase()
|
||||||
|
if (!query) return seasonDetail.value.categories
|
||||||
|
return seasonDetail.value.categories.filter((category) =>
|
||||||
|
[category.groupName, category.name, category.slug, category.description]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const selectedCategory = computed(() =>
|
||||||
|
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
seasonForm.currentPhase = detail.currentPhase
|
||||||
|
seasonForm.isCurrent = detail.isCurrent
|
||||||
|
|
||||||
|
for (const category of detail.categories) {
|
||||||
|
editForms[category.id] = {
|
||||||
|
groupName: category.groupName,
|
||||||
|
name: category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
description: category.description,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newCategoryForm.sortOrder = detail.categories.length + 1
|
||||||
|
|
||||||
|
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||||
|
selectedCategoryId.value = detail.categories[0]?.id ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filteredCategories,
|
||||||
|
(categories) => {
|
||||||
|
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||||
|
selectedCategoryId.value = categories[0]?.id ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveSeason() {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
seasonSaving.value = true
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.updateAdminSeason(selectedSeasonId.value, {
|
||||||
|
currentPhase: seasonForm.currentPhase,
|
||||||
|
isCurrent: seasonForm.isCurrent,
|
||||||
|
})
|
||||||
|
adminMessage.value = 'Jahres-Einstellungen gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
seasonSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategory(categoryId: number) {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
categorySaving.value = categoryId
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
||||||
|
adminMessage.value = 'Kategorie gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
categorySaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCategory() {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
|
||||||
|
categorySaving.value = 'new'
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||||
|
adminMessage.value = 'Neue Kategorie angelegt.'
|
||||||
|
newCategoryForm.groupName = ''
|
||||||
|
newCategoryForm.name = ''
|
||||||
|
newCategoryForm.slug = ''
|
||||||
|
newCategoryForm.description = ''
|
||||||
|
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
|
||||||
|
newCategoryForm.maxNomineesPerUser = 3
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||||
|
} finally {
|
||||||
|
categorySaving.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Jahre"
|
||||||
|
title="Jahr und Kategorien verwalten"
|
||||||
|
description="Hier steuerst du die aktive Phase, legst neue Kategorien an und pflegst bestehende Gruppen, Limits und Beschreibungen."
|
||||||
|
:icon="CalendarCog"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-6">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Award-Jahr steuern</h2>
|
||||||
|
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
|
||||||
|
Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr oeffentlich fuer Community, Voting und Archiv sichtbar ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
|
||||||
|
{{ seasonForm.isCurrent ? 'Oeffentlich aktiv' : 'Intern vorbereitet' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5 p-6">
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aktuelle Phase</span>
|
||||||
|
<input
|
||||||
|
v-model="seasonForm.currentPhase"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="z.B. Community Voting, Nominierung, Archiviert"
|
||||||
|
/>
|
||||||
|
<span class="block text-xs leading-5 text-slate-500">
|
||||||
|
Diese Phase wird als Orientierung fuer Team und spaeter auch fuer Public-Kommunikation genutzt.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
|
||||||
|
<input v-model="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
|
||||||
|
<span>
|
||||||
|
<span class="block font-semibold text-slate-800">Dieses Award-Jahr oeffentlich schalten</span>
|
||||||
|
<span class="mt-1 block text-sm leading-6 text-slate-500">
|
||||||
|
Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig oeffentlich sein.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-violet-50 bg-white/80 p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Jahr</p>
|
||||||
|
<strong class="mt-2 block text-xl text-violet-800">{{ seasonDetail.year || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-50 bg-white/80 p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kategorien</p>
|
||||||
|
<strong class="mt-2 block text-xl text-violet-800">{{ seasonDetail.categories.length }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-violet-50 bg-white/80 p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kandidaten</p>
|
||||||
|
<strong class="mt-2 block text-xl text-violet-800">{{ seasonDetail.candidates.length }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-sm leading-6 text-slate-500">
|
||||||
|
Speichert Phase und Public-Status fuer <strong class="text-slate-700">{{ seasonDetail.name }}</strong>.
|
||||||
|
</p>
|
||||||
|
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
||||||
|
{{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||||
|
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-[#f7f2ff] to-[#f7eef8] p-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<PlusCircle class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie planen</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen spaeter URL, Anzeige und Reihenfolge im Voting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 p-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
|
||||||
|
<input v-model="newCategoryForm.groupName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. Hauptpreise" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
||||||
|
<input v-model="newCategoryForm.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. VTuber des Jahres" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||||
|
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklaeren, wofuer diese Kategorie steht." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<label class="space-y-2 sm:col-span-1">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||||
|
<input v-model="newCategoryForm.slug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="vtuber-des-jahres" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||||
|
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="1" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierungslimit</span>
|
||||||
|
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="3" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-sm leading-6 text-slate-500">
|
||||||
|
Neue Kategorien sind sofort Teil des gewaehlten Award-Jahres und koennen danach unten weiter bearbeitet werden.
|
||||||
|
</p>
|
||||||
|
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||||
|
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 bg-white/75 p-6">
|
||||||
|
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien dieses Jahres</h2>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
|
Pruefe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]">
|
||||||
|
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input
|
||||||
|
v-model="categoryFilter"
|
||||||
|
type="text"
|
||||||
|
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||||
|
placeholder="Nach Name, Gruppe oder Slug suchen"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||||
|
{{ filteredCategories.length }} / {{ seasonDetail.categories.length }} sichtbar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 p-6 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
|
||||||
|
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||||
|
<button
|
||||||
|
v-for="category in filteredCategories"
|
||||||
|
:key="category.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-2xl border p-3 text-left transition"
|
||||||
|
:class="selectedCategory?.id === category.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||||
|
@click="selectedCategoryId = category.id"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
|
||||||
|
<Layers3 class="h-3 w-3" />
|
||||||
|
{{ category.groupName }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-700">
|
||||||
|
{{ category.candidateCount }} Kandidaten
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
||||||
|
Limit {{ category.maxNomineesPerUser }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ category.name }}</h3>
|
||||||
|
<p class="mt-1 line-clamp-2 text-sm leading-5 text-slate-500">{{ category.description }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">
|
||||||
|
#{{ category.sortOrder }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="filteredCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||||
|
Keine Kategorien passen zum aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedCategory" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
|
||||||
|
<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.22em] text-violet-500">Kategorie bearbeiten</p>
|
||||||
|
<h3 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h3>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
|
||||||
|
{{ selectedCategory.candidateCount }} Kandidaten
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-600">
|
||||||
|
Limit {{ selectedCategory.maxNomineesPerUser }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].groupName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Name" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].slug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].sortOrder" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="1" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
|
||||||
|
<input v-model="editForms[selectedCategory.id].maxNomineesPerUser" type="number" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="3" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-4 block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||||
|
<textarea
|
||||||
|
v-model="editForms[selectedCategory.id].description"
|
||||||
|
class="min-h-24 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="Beschreibung"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<Button :disabled="categorySaving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
|
||||||
|
{{ categorySaving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { CheckCircle2, Database, Settings, ShieldCheck } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Button from '../../components/ui/Button.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const saving = ref(false)
|
||||||
|
const adminMessage = ref('')
|
||||||
|
const adminError = ref('')
|
||||||
|
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const form = reactive({
|
||||||
|
currentPhase: '',
|
||||||
|
isCurrent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const checks = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Backend verbunden',
|
||||||
|
value: store.apiMode === 'api',
|
||||||
|
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Public-Jahr gesetzt',
|
||||||
|
value: seasonDetail.value.isCurrent,
|
||||||
|
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist oeffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Review-Schutz aktiv',
|
||||||
|
value: store.admin.riskFlags.length >= 0,
|
||||||
|
note: `${store.admin.riskFlags.length} Risikohinweise im Admin-Kontext.`,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const featureGates = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Nominierungen',
|
||||||
|
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
|
||||||
|
note: 'Public-Nominierungen sollten nur im passenden Zeitraum aktiv sein.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Voting',
|
||||||
|
state: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
|
||||||
|
note: 'Voting sollte erst aktiv sein, wenn Kategorien und Kandidaten gepflegt sind.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Community-only Ergebnis',
|
||||||
|
state: true,
|
||||||
|
note: 'Aktuell als Community-basierte Auswertung geplant.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clip-Moderation',
|
||||||
|
state: false,
|
||||||
|
note: 'Admin-API fuer ClipSubmissions fehlt noch und sollte spaeter ergaenzt werden.',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
seasonDetail,
|
||||||
|
(detail) => {
|
||||||
|
form.currentPhase = detail.currentPhase
|
||||||
|
form.isCurrent = detail.isCurrent
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
if (!selectedSeasonId.value) return
|
||||||
|
saving.value = true
|
||||||
|
adminMessage.value = ''
|
||||||
|
adminError.value = ''
|
||||||
|
try {
|
||||||
|
await store.updateAdminSeason(selectedSeasonId.value, {
|
||||||
|
currentPhase: form.currentPhase,
|
||||||
|
isCurrent: form.isCurrent,
|
||||||
|
})
|
||||||
|
adminMessage.value = 'Einstellungen gespeichert.'
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Einstellungen"
|
||||||
|
title="Public-Status und Systemchecks"
|
||||||
|
description="Hier liegen bewusst nur Einstellungen, die das aktuelle Award-Jahr oder die Admin-Betriebsbereitschaft betreffen. Kategorie-Inhalte bleiben in Kategorien/Jahre."
|
||||||
|
:icon="Settings"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahr</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Sichtbarkeit steuern</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
Diese Einstellungen werden gespeichert und beeinflussen, welches Jahr als aktueller Public-Kontext gilt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-5">
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Phase</span>
|
||||||
|
<input v-model="form.currentPhase" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Community Voting" />
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
|
||||||
|
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
|
||||||
|
<span>
|
||||||
|
<span class="block font-semibold text-slate-900">Dieses Award-Jahr oeffentlich markieren</span>
|
||||||
|
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext fuer Community, Voting und spaeter Archiv.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||||
|
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<Button :disabled="saving || !selectedSeasonId" @click="saveSettings">{{ saving ? 'Speichert ...' : 'Einstellungen speichern' }}</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Checks</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Betriebsstatus</h2>
|
||||||
|
<div class="mt-6 space-y-3">
|
||||||
|
<div v-for="check in checks" :key="check.label" class="flex gap-4 rounded-[22px] border border-violet-100 bg-white/90 p-4">
|
||||||
|
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="check.value ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||||
|
<component :is="check.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-900">{{ check.label }}</p>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">{{ check.note }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Feature Gates</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was ist aktuell aktiv?</h2>
|
||||||
|
<div class="mt-5 grid gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="gate in featureGates"
|
||||||
|
:key="gate.label"
|
||||||
|
class="rounded-[22px] border p-4"
|
||||||
|
:class="gate.state ? 'border-emerald-100 bg-emerald-50/40' : 'border-slate-100 bg-slate-50/70'"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-900">{{ gate.label }}</p>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-slate-500">{{ gate.note }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="gate.state ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'">
|
||||||
|
{{ gate.state ? 'aktiv' : 'inaktiv' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Search, ShieldAlert, UserCog } from '@lucide/vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const query = ref('')
|
||||||
|
const auditEntries = computed(() => store.admin.auditEntries)
|
||||||
|
const riskFlags = computed(() => store.admin.riskFlags)
|
||||||
|
const filteredAuditEntries = computed(() => {
|
||||||
|
const search = query.value.trim().toLowerCase()
|
||||||
|
if (!search) return auditEntries.value
|
||||||
|
return auditEntries.value.filter((entry) =>
|
||||||
|
[entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId, entry.summary]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const filteredRiskUsers = computed(() => {
|
||||||
|
const search = query.value.trim().toLowerCase()
|
||||||
|
const users = riskFlags.value.map((flag) => ({
|
||||||
|
id: flag.id,
|
||||||
|
twitchUserId: flag.twitchUserId ?? 'unbekannt',
|
||||||
|
source: flag.source,
|
||||||
|
type: flag.type,
|
||||||
|
severity: flag.severity,
|
||||||
|
ip: flag.createdFromIp,
|
||||||
|
createdAt: flag.createdAt,
|
||||||
|
}))
|
||||||
|
if (!search) return users
|
||||||
|
return users.filter((user) => [user.twitchUserId, user.source, user.type, user.ip].join(' ').toLowerCase().includes(search))
|
||||||
|
})
|
||||||
|
const adminCounts = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
|
||||||
|
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
|
||||||
|
})
|
||||||
|
const logStats = computed(() => [
|
||||||
|
{ label: 'Audit-Eintraege', value: auditEntries.value.length },
|
||||||
|
{ label: 'Admins aktiv', value: adminCounts.value.length },
|
||||||
|
{ label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="User & Logs"
|
||||||
|
title="User-Spuren und Admin-Aktionen"
|
||||||
|
description="Eine kompakte Kontrollansicht fuer Audit-Eintraege, auffaellige User und Admin-Aktivitaet. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
|
||||||
|
:icon="UserCog"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card class="p-5">
|
||||||
|
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
|
<label class="relative block">
|
||||||
|
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||||
|
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Nach Admin, Aktion, User, IP oder Objekt suchen" />
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<div v-for="stat in logStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||||
|
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||||
|
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivitaet</h2>
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3">
|
||||||
|
<span class="font-semibold text-slate-900">{{ item.admin }}</span>
|
||||||
|
<span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500">
|
||||||
|
Noch keine Admin-Aktivitaet vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Audit Log</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Letzte Aktionen</h2>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[520px] divide-y divide-violet-50 overflow-y-auto">
|
||||||
|
<div v-for="entry in filteredAuditEntries" :key="entry.id" class="px-5 py-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-900">{{ entry.summary }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Eintraege gefunden.</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-rose-50 text-rose-600">
|
||||||
|
<ShieldAlert class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffaellige User</p>
|
||||||
|
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-violet-50">
|
||||||
|
<div v-for="user in filteredRiskUsers" :key="user.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_180px_140px] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-900">{{ user.twitchUserId }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ user.type }} · {{ user.ip }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-slate-500">{{ new Date(user.createdAt).toLocaleString('de-DE') }}</span>
|
||||||
|
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||||
|
Keine auffaelligen User fuer den aktuellen Filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BarChart3, CheckCircle2, Tags, Users, Vote } from '@lucide/vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||||
|
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||||
|
import Card from '../../components/ui/Card.vue'
|
||||||
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
|
||||||
|
const store = useAwardsStore()
|
||||||
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||||
|
const totalVotes = computed(() => store.admin.metrics.find((metric) => metric.label === 'Stimmen')?.value ?? 0)
|
||||||
|
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
|
||||||
|
const votingReadiness = computed(() =>
|
||||||
|
seasonDetail.value.categories.map((category) => {
|
||||||
|
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
candidateCount,
|
||||||
|
ready: candidateCount > 0 && seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
|
||||||
|
const notReadyCategories = computed(() => votingReadiness.value.filter((category) => !category.ready))
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
|
||||||
|
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
|
||||||
|
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
|
||||||
|
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, icon: Users },
|
||||||
|
])
|
||||||
|
const votingChecklist = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Voting-Phase aktiv',
|
||||||
|
done: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
|
||||||
|
note: seasonDetail.value.currentPhase || 'Keine Phase gesetzt',
|
||||||
|
to: '/admin/settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Alle Kategorien haben Kandidaten',
|
||||||
|
done: notReadyCategories.value.every((category) => category.candidateCount > 0) && seasonDetail.value.categories.length > 0,
|
||||||
|
note: `${notReadyCategories.value.filter((category) => category.candidateCount === 0).length} Kategorien ohne Kandidaten`,
|
||||||
|
to: '/admin/categories',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Offene Reviews niedrig',
|
||||||
|
done: seasonDetail.value.pendingNominations.length === 0,
|
||||||
|
note: `${seasonDetail.value.pendingNominations.length} offene Reviews`,
|
||||||
|
to: '/admin/reviews',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<AdminPageHeader
|
||||||
|
eyebrow="Voting"
|
||||||
|
title="Voting-Status und Rankings"
|
||||||
|
description="Pruefe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivitaet erzeugen."
|
||||||
|
:icon="Vote"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSeasonToolbar />
|
||||||
|
|
||||||
|
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Card v-for="stat in stats" :key="stat.label" class="p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ stat.label }}</p>
|
||||||
|
<strong class="mt-3 block text-3xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||||
|
<component :is="stat.icon" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||||
|
<Card class="p-6">
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ranking</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien</h2>
|
||||||
|
</div>
|
||||||
|
<BarChart3 class="h-6 w-6 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div v-for="(category, index) in store.admin.topCategories" :key="category.category" class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-500">#{{ index + 1 }}</p>
|
||||||
|
<h3 class="mt-1 font-semibold text-slate-900">{{ category.category }}</h3>
|
||||||
|
</div>
|
||||||
|
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50">
|
||||||
|
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500">
|
||||||
|
Noch keine Voting-Daten vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<div class="border-b border-violet-100 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie-Check</h2>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
|
||||||
|
<div v-for="category in votingReadiness" :key="category.id" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||||
|
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</p>
|
||||||
|
</div>
|
||||||
|
<span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'">
|
||||||
|
{{ category.ready ? 'bereit' : 'pruefen' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card class="p-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Voting Checkliste</p>
|
||||||
|
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Vor dem Public Push</h2>
|
||||||
|
<div class="mt-5 grid gap-3 lg:grid-cols-3">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in votingChecklist"
|
||||||
|
:key="item.label"
|
||||||
|
:to="item.to"
|
||||||
|
class="rounded-[22px] border p-4 transition hover:-translate-y-0.5 hover:bg-violet-50/50"
|
||||||
|
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/50'"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-900">{{ item.label }}</p>
|
||||||
|
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'">
|
||||||
|
{{ item.done ? 'ok' : 'pruefen' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user