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 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(
|
||||
IEnumerable<AdminMetricDto> Metrics,
|
||||
IEnumerable<AdminActivityDto> Activities,
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories);
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||
IEnumerable<AdminRiskFlagDto> RiskFlags,
|
||||
IEnumerable<AdminAuditEntryDto> AuditEntries);
|
||||
|
||||
public sealed record AdminSeasonListItemDto(
|
||||
int Id,
|
||||
@@ -76,3 +99,5 @@ public sealed record ApproveNominationRequest(
|
||||
string? DisplayName,
|
||||
string? ChannelSlug,
|
||||
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<VoteEntry> VoteEntries => Set<VoteEntry>();
|
||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
|
||||
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.DisplayName).HasMaxLength(120);
|
||||
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);
|
||||
|
||||
@@ -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 DisplayName { get; set; } = string.Empty;
|
||||
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 LastSeenAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
+362
-19
@@ -2,6 +2,7 @@ using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var connectionString = builder.Configuration["VTSA_POSTGRES"]
|
||||
@@ -58,6 +59,79 @@ static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsD
|
||||
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())
|
||||
{
|
||||
app.UseSwagger();
|
||||
@@ -86,15 +160,18 @@ using (var scope = app.Services.CreateScope())
|
||||
try
|
||||
{
|
||||
await SessionBootstrapper.EnsureAsync(db);
|
||||
await OperationalTablesBootstrapper.EnsureAsync(db);
|
||||
}
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -102,11 +179,32 @@ app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext
|
||||
TwitchUserId = request.TwitchUserId.Trim(),
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
|
||||
CreatedFromIp = createdFromIp,
|
||||
UserAgent = userAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastSeenAt = DateTimeOffset.UtcNow,
|
||||
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);
|
||||
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." });
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
@@ -348,9 +465,44 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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")
|
||||
.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." });
|
||||
}
|
||||
|
||||
var ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = request.SeasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
Status = "submitted",
|
||||
};
|
||||
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,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).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();
|
||||
|
||||
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")
|
||||
.WithOpenApi();
|
||||
@@ -433,6 +653,43 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
||||
.Take(5)
|
||||
.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(
|
||||
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("Reviews offen", reviewCount, "Freitext und Dubletten"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
|
||||
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
|
||||
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
|
||||
},
|
||||
topCategories);
|
||||
activityItems,
|
||||
topCategories,
|
||||
riskFlags,
|
||||
auditEntries);
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
@@ -594,6 +848,14 @@ app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
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.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();
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
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.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();
|
||||
|
||||
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.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();
|
||||
|
||||
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.CandidateId = null;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"nomination.reject",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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")
|
||||
.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();
|
||||
|
||||
@@ -28,6 +28,7 @@ The frontend uses a lightweight local session flow for development:
|
||||
- Sign in from the header
|
||||
- `Viewer Login` unlocks nomination and voting
|
||||
- `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
|
||||
|
||||
@@ -85,3 +86,4 @@ curl -X POST http://localhost:5084/api/auth/dev-login \
|
||||
- Session endpoints live under `/api/auth/*`
|
||||
- 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
|
||||
- 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(
|
||||
() => 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(() =>
|
||||
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
|
||||
)
|
||||
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
async function login(role: 'viewer' | 'admin') {
|
||||
loginError.value = ''
|
||||
@@ -56,15 +57,21 @@ async function login(role: 'viewer' | 'admin') {
|
||||
<span>{{ currentLabel }}</span>
|
||||
</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">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<header
|
||||
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">
|
||||
<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)]">
|
||||
<Star class="h-5 w-5" />
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
</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">
|
||||
{{ authStore.session.displayName }} · {{ authStore.session.role }}
|
||||
</div>
|
||||
<Button variant="ghost" @click="authStore.logout()">Logout</Button>
|
||||
<Button variant="ghost" @click="authStore.logout()">Abmelden</Button>
|
||||
</template>
|
||||
<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>
|
||||
</template>
|
||||
</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 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.twitchUserId" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Twitch User ID" />
|
||||
<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 Nutzer-ID" />
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button :disabled="authStore.loading" @click="login('viewer')">
|
||||
{{ authStore.loading ? 'Loggt ein ...' : 'Viewer Login' }}
|
||||
{{ authStore.loading ? 'Loggt ein ...' : 'Viewer-Login' }}
|
||||
</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>
|
||||
<p v-if="loginError" class="mt-3 text-sm text-rose-700">{{ loginError }}</p>
|
||||
</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">
|
||||
<RouterLink
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
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 }}
|
||||
</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,
|
||||
} 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'
|
||||
|
||||
function getAuthToken() {
|
||||
@@ -31,6 +31,8 @@ async function getJson<T>(path: string): Promise<T> {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
: undefined,
|
||||
}).catch(() => {
|
||||
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||
})
|
||||
if (!response.ok) {
|
||||
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}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {
|
||||
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -96,6 +100,12 @@ export const api = {
|
||||
'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 }
|
||||
|
||||
+131
-4
@@ -1,7 +1,19 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
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 NominationsView from './views/NominationsView.vue'
|
||||
import VotingView from './views/VotingView.vue'
|
||||
@@ -9,7 +21,15 @@ import WinnersView from './views/WinnersView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
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 }
|
||||
},
|
||||
routes: [
|
||||
@@ -24,6 +44,7 @@ const router = createRouter({
|
||||
component: NominationsView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -32,6 +53,7 @@ const router = createRouter({
|
||||
component: VotingView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -41,11 +63,116 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: AdminView,
|
||||
component: AdminLayoutView,
|
||||
meta: {
|
||||
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 = {
|
||||
metrics: [
|
||||
{ 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: 'Reviews offen', value: 47, note: '14 neu' },
|
||||
],
|
||||
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: 'Alias-Merge fuer Hoshimi Miyu reviewt', age: 'vor 18 Min.' },
|
||||
{ label: 'Alias-Zusammenfuehrung fuer Hoshimi Miyu geprueft', age: 'vor 18 Min.' },
|
||||
],
|
||||
topCategories: [
|
||||
{ category: 'VTuber des Jahres', votes: 186321 },
|
||||
{ category: 'Bestes Live Event', votes: 132550 },
|
||||
{ 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[] = [
|
||||
{ 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 = {
|
||||
@@ -119,7 +144,7 @@ const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
|
||||
categories: [
|
||||
{
|
||||
id: 1,
|
||||
groupName: 'Main Awards',
|
||||
groupName: 'Hauptpreise',
|
||||
name: 'VTuber des Jahres',
|
||||
slug: 'vtuber-des-jahres',
|
||||
description: 'Die groesste Auszeichnung des Jahres.',
|
||||
@@ -158,6 +183,8 @@ const emptyAdmin: AdminDashboardResponse = {
|
||||
metrics: [],
|
||||
activities: [],
|
||||
topCategories: [],
|
||||
riskFlags: [],
|
||||
auditEntries: [],
|
||||
}
|
||||
|
||||
const emptyAdminSeasons: AdminSeasonListItem[] = []
|
||||
@@ -181,6 +208,7 @@ export const useAwardsStore = defineStore('awards', {
|
||||
admin: fallbackAdmin as AdminDashboardResponse,
|
||||
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
|
||||
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
|
||||
adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null,
|
||||
loading: false,
|
||||
apiMode: 'fallback' as 'api' | 'fallback',
|
||||
}),
|
||||
@@ -210,22 +238,39 @@ export const useAwardsStore = defineStore('awards', {
|
||||
try {
|
||||
this.admin = await api.getAdminDashboard()
|
||||
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'
|
||||
} catch {
|
||||
this.admin = emptyAdmin
|
||||
this.adminSeasons = emptyAdminSeasons
|
||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||
this.adminSelectedSeasonId = null
|
||||
}
|
||||
},
|
||||
async loadAdminSeasonDetail(seasonId: number) {
|
||||
try {
|
||||
this.adminSelectedSeasonId = seasonId
|
||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
|
||||
this.apiMode = 'api'
|
||||
} catch {
|
||||
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) {
|
||||
return api.submitNomination(payload)
|
||||
},
|
||||
@@ -269,5 +314,10 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.loadAdmin()
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
metrics: AdminMetric[]
|
||||
activities: AdminActivity[]
|
||||
topCategories: AdminTopCategory[]
|
||||
riskFlags: AdminRiskFlag[]
|
||||
auditEntries: AdminAuditEntry[]
|
||||
}
|
||||
|
||||
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">
|
||||
<div class="flex items-center gap-3 text-violet-600">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -81,17 +81,17 @@ const heroYear = computed(() => store.overview.year)
|
||||
{{ store.overview.currentPhase }}
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ const heroYear = computed(() => store.overview.year)
|
||||
</div>
|
||||
<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="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 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>
|
||||
@@ -202,9 +202,9 @@ const heroYear = computed(() => store.overview.year)
|
||||
<WandSparkles class="h-5 w-5 text-amber-500" />
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
|
||||
</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">
|
||||
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>
|
||||
</Card>
|
||||
</section>
|
||||
@@ -245,7 +245,7 @@ const heroYear = computed(() => store.overview.year)
|
||||
<ul class="mt-5 space-y-3 text-slate-600">
|
||||
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</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>
|
||||
<div class="mt-6">
|
||||
<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]">
|
||||
<Card class="p-6">
|
||||
<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">
|
||||
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
|
||||
</p>
|
||||
|
||||
@@ -82,7 +82,7 @@ async function submitNomination() {
|
||||
<ul class="mt-5 space-y-4 text-slate-600">
|
||||
<li>Pro Kategorie nur eine Nominierung derselben Person.</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>
|
||||
</ul>
|
||||
</Card>
|
||||
@@ -91,7 +91,7 @@ async function submitNomination() {
|
||||
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<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">
|
||||
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>
|
||||
|
||||
<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="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">
|
||||
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>
|
||||
|
||||
<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-4">
|
||||
<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">
|
||||
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
|
||||
</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