Compare commits

...

20 Commits

Author SHA1 Message Date
AzuTear a2d6a5edc0 Iterate admin pages with smarter workflows 2026-06-18 01:00:58 +02:00
AzuTear 0fa763667d Expand admin navigation and pages 2026-06-18 00:21:40 +02:00
AzuTear 178f014a4a Make admin reviews scalable 2026-06-18 00:09:25 +02:00
AzuTear 1e101ee2fb Make admin categories scalable 2026-06-18 00:05:40 +02:00
AzuTear 65ac0861da Refine admin sidebar navigation 2026-06-18 00:00:17 +02:00
AzuTear adaea5cbce Improve admin year setup card 2026-06-17 14:35:09 +02:00
AzuTear f85a0d7c82 Improve admin year context toolbar 2026-06-17 14:29:37 +02:00
AzuTear 37f7cad5dc Soften dashboard bar colors 2026-06-17 14:28:01 +02:00
AzuTear 9473ff214a Replace dashboard day trend with yearly metrics 2026-06-17 14:22:00 +02:00
AzuTear 78bf9fd503 Redesign admin dashboard analytics 2026-06-17 14:16:27 +02:00
AzuTear f3696154b2 Use dashboard label in admin 2026-06-17 14:13:07 +02:00
AzuTear 6b679937fe Unify frontend German UI copy 2026-06-17 14:11:30 +02:00
AzuTear 12cf63ef49 Improve admin candidates interface 2026-06-17 13:57:33 +02:00
AzuTear 567f0e2ebf Rename season UI language to years 2026-06-17 13:45:06 +02:00
AzuTear 33975a633c Separate active season admin card 2026-06-17 13:39:54 +02:00
AzuTear 6d08d6ea01 Remove admin summary banner 2026-06-17 13:37:14 +02:00
AzuTear e2c74a7378 Compact admin layout and preserve admin scroll 2026-06-17 13:30:27 +02:00
AzuTear 4a211189f0 Improve admin navigation and local API reachability 2026-06-17 13:24:41 +02:00
AzuTear 953257bcef Refactor admin panel into categorized navigation 2026-06-17 12:37:17 +02:00
AzuTear 92dd6f7432 Add risk center and editable submission flows 2026-06-17 12:01:57 +02:00
34 changed files with 3851 additions and 621 deletions
+26 -1
View File
@@ -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);
+26
View File
@@ -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);
""");
}
+13
View File
@@ -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; }
}
+20
View File
@@ -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; }
}
+2
View File
@@ -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
View File
@@ -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();
+2
View File
@@ -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
View File
@@ -1 +1 @@
VITE_API_URL=http://localhost:5084
VITE_API_URL=http://127.0.0.1:5084
+21 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
},
],
},
],
})
+56 -6
View File
@@ -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
},
},
})
+25
View File
@@ -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 {
-560
View File
@@ -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>
+11 -11
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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>
+120
View File
@@ -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>
+216
View File
@@ -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>