Align backend with frontend, make clips end-to-end, polish admin

Backend
- Add ClipSubmission entity + table (runtime bootstrapper) and
  POST /api/public/clips (server-derives platform from the link)
- Surface clip submissions in the admin season detail
- Add DELETE candidate/category/clip endpoints with audit entries

Frontend
- Clips admin: real moderation view (list, open link, delete) instead
  of placeholder; wired clipSubmissions through types/api/store
- Categories admin: add delete with confirm modal (matches Candidates)
- Voting ranking bar uses the unified purple gradient
- Fix ASCII transliterations to proper German umlauts across admin
- Remove orphan AdminView 2.vue

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-18 07:53:19 +02:00
parent cca297a4eb
commit 24f9a69022
22 changed files with 504 additions and 145 deletions
+13 -1
View File
@@ -67,6 +67,17 @@ public sealed record AdminNominationReviewItemDto(
string CandidateText,
DateTimeOffset CreatedAt);
public sealed record AdminClipSubmissionItemDto(
int Id,
int? CategoryId,
string SubmittedByTwitchId,
string ClipUrl,
string Title,
string Creator,
string Platform,
string Status,
DateTimeOffset CreatedAt);
public sealed record AdminSeasonDetailResponse(
int Id,
int Year,
@@ -75,7 +86,8 @@ public sealed record AdminSeasonDetailResponse(
bool IsCurrent,
IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations);
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
public sealed record UpdateSeasonRequest(
string CurrentPhase,
+8
View File
@@ -77,3 +77,11 @@ public sealed record CreateVoteRequest(
int SeasonId,
string TwitchUserId,
VoteEntryRequest[] Entries);
public sealed record CreateClipRequest(
int Year,
int? CategoryId,
string TwitchUserId,
string ClipUrl,
string Title,
string Creator);
+13
View File
@@ -15,6 +15,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -90,6 +91,18 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Summary).HasMaxLength(240);
});
modelBuilder.Entity<ClipSubmission>(entity =>
{
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.ClipUrl).HasMaxLength(500);
entity.Property(item => item.Title).HasMaxLength(200);
entity.Property(item => item.Creator).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.Status });
});
SeedData.Apply(modelBuilder);
}
}
@@ -49,5 +49,22 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt" DESC);
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"CategoryId" integer NULL,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"ClipUrl" character varying(500) NOT NULL,
"Title" character varying(200) NOT NULL,
"Creator" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
"Status" character varying(20) NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status");
""");
}
+16
View File
@@ -0,0 +1,16 @@
namespace Backend.Domain;
public sealed class ClipSubmission
{
public int Id { get; set; }
public int SeasonId { get; set; }
public int? CategoryId { get; set; }
public string SubmittedByTwitchId { get; set; } = string.Empty;
public string ClipUrl { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public string Status { get; set; } = "pending";
public string CreatedFromIp { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}
+179 -1
View File
@@ -621,6 +621,70 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
.WithName("CreateVote")
.WithOpenApi();
app.MapPost("/api/public/clips", async (HttpContext context, CreateClipRequest request, AwardsDbContext db) =>
{
var clipUrl = request.ClipUrl?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(clipUrl))
{
return Results.BadRequest(new { message = "A clip link is required." });
}
var loweredUrl = clipUrl.ToLowerInvariant();
var platform = loweredUrl.Contains("twitch.tv")
? "Twitch"
: loweredUrl.Contains("youtube.com") || loweredUrl.Contains("youtu.be")
? "YouTube"
: "Other";
if (platform == "Other")
{
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
if (season is null)
{
return Results.BadRequest(new { message = "The selected season does not exist." });
}
if (request.CategoryId is int categoryId)
{
var categoryExists = await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
if (!categoryExists)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
}
var session = await ResolveSessionAsync(context, db);
var submitterId = session?.TwitchUserId ?? request.TwitchUserId;
if (string.IsNullOrWhiteSpace(submitterId))
{
return Results.BadRequest(new { message = "A logged in user is required to submit clips." });
}
var clip = new ClipSubmission
{
SeasonId = season.Id,
CategoryId = request.CategoryId,
SubmittedByTwitchId = submitterId,
ClipUrl = clipUrl,
Title = request.Title?.Trim() ?? string.Empty,
Creator = request.Creator?.Trim() ?? string.Empty,
Platform = platform,
Status = "pending",
CreatedFromIp = ReadClientIp(context),
CreatedAt = DateTimeOffset.UtcNow,
};
db.ClipSubmissions.Add(clip);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, clipId = clip.Id });
})
.WithName("CreateClip")
.WithOpenApi();
app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
@@ -809,6 +873,23 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
item.CreatedAt))
.ToArrayAsync();
var clipSubmissions = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderByDescending(item => item.CreatedAt)
.Take(100)
.Select(item => new AdminClipSubmissionItemDto(
item.Id,
item.CategoryId,
item.SubmittedByTwitchId,
item.ClipUrl,
item.Title,
item.Creator,
item.Platform,
item.Status,
item.CreatedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse(
season.Id,
season.Year,
@@ -817,7 +898,8 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
season.IsCurrent,
categories,
candidates,
pendingNominations));
pendingNominations,
clipSubmissions));
})
.WithName("GetAdminSeasonDetail")
.WithOpenApi();
@@ -1013,6 +1095,102 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
.WithName("UpdateAdminCandidate")
.WithOpenApi();
app.MapDelete("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
if (candidate is null)
{
return Results.NotFound();
}
db.Candidates.Remove(candidate);
AddAuditEntry(
db,
session.TwitchUserId,
"candidate.delete",
"candidate",
candidate.Id.ToString(),
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
new { candidate.CategoryId, candidate.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, candidateId });
})
.WithName("DeleteAdminCandidate")
.WithOpenApi();
app.MapDelete("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
if (category is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
if (candidates.Length > 0)
{
db.Candidates.RemoveRange(candidates);
}
db.Categories.Remove(category);
AddAuditEntry(
db,
session.TwitchUserId,
"category.delete",
"category",
category.Id.ToString(),
$"Kategorie {category.Name} wurde gelöscht.",
new { removedCandidates = candidates.Length });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, categoryId });
})
.WithName("DeleteAdminCategory")
.WithOpenApi();
app.MapDelete("/api/admin/clips/{clipId:int}", async (HttpContext context, int clipId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
if (clip is null)
{
return Results.NotFound();
}
db.ClipSubmissions.Remove(clip);
AddAuditEntry(
db,
session.TwitchUserId,
"clip.delete",
"clip",
clip.Id.ToString(),
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
new { clip.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, clipId });
})
.WithName("DeleteAdminClip")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);