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
+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);