feat(cms): compose Session hero image into the upload form (8.F)

Session upload now carries a deferred hero-image input; the submit handler
creates the release then POSTs the held hero to the existing resource-addressed
endpoint. Hero is optional with a non-blocking warn-if-missing gate. The
per-row hero upload in CmsSessionBrowser remains the replace/correct path.
This commit is contained in:
daniel-c-harvey
2026-06-13 20:46:46 -04:00
parent 18f4b596f2
commit 4701804594
4 changed files with 110 additions and 8 deletions
@@ -66,6 +66,8 @@
<MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
Disabled="Disabled" />
</MudPaper>
@@ -85,6 +87,11 @@
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
// Session-only — the held hero-image file, threaded through MediumFields to SessionFields.
// Ignored for Cut/Mix media. The parent (BatchUpload) owns it and uploads it after create.
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
// 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.
[Parameter] public string? ExistingImagePath { get; set; }
@@ -26,6 +26,7 @@
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile"
Disabled="_uploading" />
@if (_medium == ReleaseMedium.Cut)
@@ -108,6 +109,12 @@
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
// Session-only: the hero image is resource-addressed and cannot be uploaded until the release
// exists, so it is held here and POSTed to api/release/{id}/session/hero-image after create.
private IBrowserFile? _heroImageFile;
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
private bool _heroWarningAcknowledged;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
@@ -244,6 +251,18 @@
return;
}
// A Session's hero is its primary visual identity on the public detail page. It is optional —
// a Session can be authored without one and set later from the Sessions browser — but a missing
// hero is usually an oversight, so warn (do not block). The first submit without a hero shows the
// warning and primes acknowledgment; a second submit proceeds.
if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged)
{
_heroWarningAcknowledged = true;
_errorMessage = "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).";
return;
}
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
@@ -336,6 +355,34 @@
}
}
// Session hero image is resource-addressed, so it is uploaded here — after the
// release exists and we have its id — within the same submit gesture. Non-blocking:
// the Session is persisted; a failed hero upload is recoverable from the Sessions
// browser's per-row Set/Replace hero action.
if (_medium == ReleaseMedium.Session
&& _heroImageFile is { } heroFile
&& result.Value.ReleaseId is { } sessionReleaseId)
{
try
{
await using var heroStream = heroFile.OpenReadStream(maxAllowedSize: 50_000_000);
var heroResult = await CmsReleaseService.UploadSessionHeroImageAsync(
sessionReleaseId, heroStream, heroFile.Name, heroFile.ContentType);
if (!heroResult.Success)
{
var heroError = heroResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Logger.LogWarning("Batch upload: hero image upload failed for release {ReleaseId} ('{TrackName}'): {Error}",
sessionReleaseId, row.TrackName, heroError);
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
}
}
catch (Exception heroEx)
{
Logger.LogError(heroEx, "Batch upload: exception uploading hero image for release {ReleaseId}", sessionReleaseId);
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
}
}
// 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.
// Non-blocking: the track is persisted; a failed trigger is recoverable from
@@ -1,4 +1,5 @@
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@* The single dispatch point for medium-conditional form fields. All five upload/edit forms embed this
one component; the @switch below is the ONLY place medium-specific form shape is decided. Adding a
@@ -26,7 +27,9 @@
Disabled="Disabled" />
break;
case ReleaseMedium.Session:
<SessionFields />
<SessionFields HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
Disabled="Disabled" />
break;
case ReleaseMedium.Mix:
<MixFields />
@@ -42,5 +45,9 @@
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
// Session-only — the held hero-image file, uploaded after create. Ignored for Cut/Mix.
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
}
@@ -1,10 +1,51 @@
@using Microsoft.AspNetCore.Components.Forms
@* Session-medium fields. The hero image is resource-addressed (POST api/release/{id}/session/hero-image)
and therefore set after the release exists — managed per-row in the Sessions browser, not at create
time when no release id is yet assigned. This section states that contract so the admin knows where
the hero image is managed; it carries no input of its own. *@
and therefore cannot be uploaded until the release exists. The file is selected here, held by the
parent form, and uploaded after the create call returns a release id — the same deferred-upload
pattern AlbumHeaderFields uses for cover art ("Will upload on submit"). The hero is optional; the
parent warns (does not block) when a Session is submitted without one. *@
<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>
<MudField Label="Hero Image" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
<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)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedHero.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="Disabled"
OnClick="ClearHeroFile"
aria-label="Cancel hero image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No hero image — optional, but recommended.</MudText>
}
<InputFile OnChange="HandleHeroFileSelected" accept="image/*" disabled="@Disabled" />
@if (HeroImageFile is not null)
{
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
}
</MudStack>
</MudField>
</MudItem>
@code {
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
private Task HandleHeroFileSelected(InputFileChangeEventArgs e) =>
HeroImageFileChanged.InvokeAsync(e.File);
private Task ClearHeroFile() =>
HeroImageFileChanged.InvokeAsync(null);
}