Add winner archive, host image upload and live demo data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s

Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-29 21:47:52 +02:00
parent f323e5a82c
commit 6b3b0360e7
92 changed files with 7412 additions and 583 deletions
+139 -37
View File
@@ -14,6 +14,15 @@ public static class AdminSiteSettingsEndpoints
private const string FallbackMaintenanceTitle = "Sternenpause";
private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
private const string DefaultHostImageUrl = "/assets/amaterasu2sei_2.png";
private const int MaxHostImageBytes = 8 * 1024 * 1024;
private static readonly IReadOnlyDictionary<string, string> AllowedHostImageContentTypes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["image/png"] = ".png",
["image/jpeg"] = ".jpg",
["image/webp"] = ".webp",
};
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
@@ -25,6 +34,10 @@ public static class AdminSiteSettingsEndpoints
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UpdateAdminSiteSettings")
.WithOpenApi();
group.MapPost("/site-settings/host-image", UploadHostImage)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UploadAdminHostImage")
.WithOpenApi();
group.MapGet("/operational-settings", GetOperationalSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminOperationalSettings")
@@ -68,43 +81,76 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
return Results.Ok(new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
settings.StreamBannerLiveButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
settings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
settings.StreamBannerCompletedButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]"));
return Results.Ok(MapSiteSettingsResponse(settings));
}
private static async Task<IResult> UploadHostImage(
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
if (!context.Request.HasFormContentType)
{
return Results.BadRequest(new { message = "Bitte ein Bild als Formular-Upload senden." });
}
var form = await context.Request.ReadFormAsync(context.RequestAborted);
var file = form.Files.GetFile("file") ?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0)
{
return Results.BadRequest(new { message = "Bitte ein Hostbild auswählen." });
}
if (file.Length > MaxHostImageBytes)
{
return Results.BadRequest(new { message = "Hostbild ist zu groß. Maximal erlaubt sind 8 MB." });
}
if (!AllowedHostImageContentTypes.TryGetValue(file.ContentType, out var expectedExtension))
{
return Results.BadRequest(new { message = "Bitte PNG, JPG oder WebP hochladen." });
}
var extension = Path.GetExtension(file.FileName);
if (!string.IsNullOrWhiteSpace(extension)
&& !string.Equals(extension, expectedExtension, StringComparison.OrdinalIgnoreCase)
&& !(string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
&& string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)))
{
return Results.BadRequest(new { message = "Dateiendung und Bildtyp passen nicht zusammen." });
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream((int)file.Length);
await stream.CopyToAsync(memory, context.RequestAborted);
settings.HostImageData = memory.ToArray();
settings.HostImageContentType = file.ContentType;
settings.HostImageUpdatedAt = DateTimeOffset.UtcNow;
var session = AdminEndpointConventions.CurrentSession(context);
adminAuditService.AddEntry(
session.TwitchUserId,
"site-settings.host-image.upload",
"site-settings",
settings.Id.ToString(),
"Landingpage-Hostbild wurde aktualisiert.",
new
{
fileName = file.FileName,
fileSize = file.Length,
file.ContentType,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(MapSiteSettingsResponse(settings));
}
private static async Task<IResult> UpdateSiteSettings(
@@ -129,6 +175,7 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.HostArtistName = request.HostArtistName.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
@@ -189,6 +236,61 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(new { saved = true });
}
private static AdminSiteSettingsResponse MapSiteSettingsResponse(SiteSettings settings)
{
return new AdminSiteSettingsResponse(
settings.HostDisplayName,
settings.HostTagline,
settings.HostArtistName,
BuildHostImageUrl(settings),
settings.NewsletterUrl,
settings.ShareXUrl,
settings.ShareDiscordUrl,
settings.PrivacyEmail,
settings.PrivacyPolicyContent,
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
settings.ShowactsUrl,
settings.ShowactsContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLiveButtonLabel),
settings.StreamBannerLiveButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerLockedButtonLabel),
settings.StreamBannerUseCompletedContent,
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedEyebrow),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedTitle),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedText),
SeasonMappings.NormalizePlainTextContent(settings.StreamBannerCompletedButtonLabel),
settings.StreamBannerCompletedButtonUrl,
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.AwardsSectionDescription),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionTitle),
SeasonMappings.NormalizePlainTextContent(settings.SubcategoriesSectionDescription),
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings),
settings.ShowactFormSchemaJson ?? "[]");
}
internal static string BuildHostImageUrl(SiteSettings settings)
{
if (settings.HostImageData is not { Length: > 0 })
{
return DefaultHostImageUrl;
}
var version = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
?? settings.HostImageData.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
return $"/api/public/host-image?v={version}";
}
private static IResult? NormalizeSiteSettingsUrls(
UpdateSiteSettingsRequest request,
out PublicSiteUrlSettings normalizedUrls,