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:
+179
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user