fix(cms): gate Session hero input to upload path; warn (not error) on missing hero

Edit forms (BatchEdit/TrackEdit/TrackNew) show the guidance alert instead of an
inert picker, via an AllowHeroUpload flag. Missing-hero nudge is Severity.Warning;
null-ReleaseId hero drop is now logged.
This commit is contained in:
daniel-c-harvey
2026-06-13 20:55:34 -04:00
parent 4701804594
commit 62dd9d5c03
4 changed files with 81 additions and 36 deletions
@@ -68,6 +68,7 @@
@bind-ReleaseType="ReleaseTypeBinding" @bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile" HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged" HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" /> Disabled="Disabled" />
</MudPaper> </MudPaper>
@@ -92,6 +93,10 @@
[Parameter] public IBrowserFile? HeroImageFile { get; set; } [Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; } [Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
// Gates the hero file picker in SessionFields (threaded to MediumFields → SessionFields).
// Set true only on the BatchUpload create path; leave false/absent on all edit paths.
[Parameter] public bool AllowHeroUpload { get; set; }
// BatchEdit only: when set (and no new file picked), preview the release's current cover. // BatchEdit only: when set (and no new file picked), preview the release's current cover.
// The parent nulls this to drop the preview when the admin clears the existing cover. // The parent nulls this to drop the preview when the admin clears the existing cover.
[Parameter] public string? ExistingImagePath { get; set; } [Parameter] public string? ExistingImagePath { get; set; }
@@ -27,6 +27,7 @@
MediumChanged="OnMediumChanged" MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile" @bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile" @bind-HeroImageFile="_heroImageFile"
AllowHeroUpload="true"
Disabled="_uploading" /> Disabled="_uploading" />
@if (_medium == ReleaseMedium.Cut) @if (_medium == ReleaseMedium.Cut)
@@ -72,6 +73,11 @@
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert> <MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
} }
@if (!string.IsNullOrEmpty(_warningMessage))
{
<MudAlert Severity="Severity.Warning" Class="mt-4">@_warningMessage</MudAlert>
}
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4"> <MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text" <MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))" OnClick="@(() => Navigation.NavigateTo("/tracks"))"
@@ -105,6 +111,8 @@
private bool _uploading; private bool _uploading;
private int _uploadedCount; private int _uploadedCount;
private string? _errorMessage; private string? _errorMessage;
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
private string? _warningMessage;
private IBrowserFile? _selectedImageFile; private IBrowserFile? _selectedImageFile;
private string? _imagePath; private string? _imagePath;
@@ -205,6 +213,7 @@
private async Task SubmitAsync() private async Task SubmitAsync()
{ {
_errorMessage = null; _errorMessage = null;
_warningMessage = null;
if (string.IsNullOrWhiteSpace(_albumName)) if (string.IsNullOrWhiteSpace(_albumName))
{ {
@@ -258,7 +267,7 @@
if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged) if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged)
{ {
_heroWarningAcknowledged = true; _heroWarningAcknowledged = true;
_errorMessage = "No hero image selected. A Session usually needs one — you can add it now, " _warningMessage = "No hero image selected. A Session usually needs one — you can add it now, "
+ "or submit again to create the Session without it (set the hero later from the Sessions browser)."; + "or submit again to create the Session without it (set the hero later from the Sessions browser).";
return; return;
} }
@@ -382,6 +391,15 @@
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning); Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
} }
} }
else if (_medium == ReleaseMedium.Session && _heroImageFile is not null)
{
// ReleaseId was null on a Session track result — internal inconsistency.
// Hero file is held but cannot be uploaded without a release id; log and
// surface so the admin can set it from the Sessions browser.
Logger.LogWarning("Batch upload: Session track '{TrackName}' (id={Id}) has no ReleaseId — hero image dropped",
row.TrackName, result.Value.Id);
Snackbar.Add("Session uploaded, but the hero image could not be linked (no release id). Set it from the Sessions browser.", Severity.Warning);
}
// Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS // Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS
// computes nothing — the API derives the datum from the audio it just stored. // computes nothing — the API derives the datum from the audio it just stored.
@@ -29,6 +29,7 @@
case ReleaseMedium.Session: case ReleaseMedium.Session:
<SessionFields HeroImageFile="HeroImageFile" <SessionFields HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged" HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" /> Disabled="Disabled" />
break; break;
case ReleaseMedium.Mix: case ReleaseMedium.Mix:
@@ -49,5 +50,9 @@
[Parameter] public IBrowserFile? HeroImageFile { get; set; } [Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; } [Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
// Gates the hero file picker in SessionFields. True on the BatchUpload create path;
// false/absent on all edit paths so SessionFields falls back to the guidance alert.
[Parameter] public bool AllowHeroUpload { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
} }
@@ -1,48 +1,65 @@
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@* Session-medium fields. The hero image is resource-addressed (POST api/release/{id}/session/hero-image) @* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
and therefore cannot be uploaded until the release exists. The file is selected here, held by the file picker is shown — the file is held by the parent and POSTed after the release is created.
parent form, and uploaded after the create call returns a release id — the same deferred-upload When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
pattern AlbumHeaderFields uses for cover art ("Will upload on submit"). The hero is optional; the instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
parent warns (does not block) when a Session is submitted without one. *@ a dead/inert control on edit forms that do not wire the hero callbacks. *@
<MudItem xs="12"> @if (AllowHeroUpload)
<MudField Label="Hero Image" Variant="Variant.Outlined" InnerPadding="false"> {
<MudStack Spacing="3"> <MudItem xs="12">
<MudText Typo="Typo.body2" Color="Color.Default"> <MudField Label="Hero Image" Variant="Variant.Outlined" InnerPadding="false">
Sessions are single-track live releases. The hero image is the session's primary visual identity. <MudStack Spacing="3">
</MudText> <MudText Typo="Typo.body2" Color="Color.Default">
Sessions are single-track live releases. The hero image is the session's primary visual identity.
</MudText>
@if (HeroImageFile is { } selectedHero) @if (HeroImageFile is { } selectedHero)
{ {
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedHero.Name</MudText> <MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedHero.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear" <MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error" Color="Color.Error"
Size="Size.Small" Size="Size.Small"
Disabled="Disabled" Disabled="Disabled"
OnClick="ClearHeroFile" OnClick="ClearHeroFile"
aria-label="Cancel hero image selection" /> aria-label="Cancel hero image selection" />
</MudStack> </MudStack>
} }
else else
{ {
<MudText Typo="Typo.body2" Color="Color.Default">No hero image — optional, but recommended.</MudText> <MudText Typo="Typo.body2" Color="Color.Default">No hero image — optional, but recommended.</MudText>
} }
<InputFile OnChange="HandleHeroFileSelected" accept="image/*" disabled="@Disabled" /> <InputFile OnChange="HandleHeroFileSelected" accept="image/*" disabled="@Disabled" />
@if (HeroImageFile is not null) @if (HeroImageFile is not null)
{ {
<MudText Typo="Typo.caption">Will upload on submit.</MudText> <MudText Typo="Typo.caption">Will upload on submit.</MudText>
} }
</MudStack> </MudStack>
</MudField> </MudField>
</MudItem> </MudItem>
}
else
{
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Sessions are single-track live releases. After upload, set the hero image from the
<strong>Release Archive → Sessions</strong> browser.
</MudAlert>
</MudItem>
}
@code { @code {
[Parameter] public IBrowserFile? HeroImageFile { get; set; } [Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; } [Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
// When true (BatchUpload create path), render the hero file picker.
// When false/absent (edit paths), render the guidance alert directing the admin to the
// Sessions browser — no dead control where callbacks are unwired.
[Parameter] public bool AllowHeroUpload { get; set; }
private Task HandleHeroFileSelected(InputFileChangeEventArgs e) => private Task HandleHeroFileSelected(InputFileChangeEventArgs e) =>
HeroImageFileChanged.InvokeAsync(e.File); HeroImageFileChanged.InvokeAsync(e.File);