Merge p9-w8-8f-session-hero-form into dev (8.F: Session hero image in upload form)

This commit is contained in:
daniel-c-harvey
2026-06-13 21:00:29 -04:00
4 changed files with 158 additions and 11 deletions
@@ -66,6 +66,9 @@
<MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
</MudPaper>
@@ -85,6 +88,15 @@
[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; }
// 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.
// The parent nulls this to drop the preview when the admin clears the existing cover.
[Parameter] public string? ExistingImagePath { get; set; }
@@ -26,6 +26,8 @@
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile"
AllowHeroUpload="true"
Disabled="_uploading" />
@if (_medium == ReleaseMedium.Cut)
@@ -71,6 +73,11 @@
<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">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
@@ -104,10 +111,18 @@
private bool _uploading;
private int _uploadedCount;
private string? _errorMessage;
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
private string? _warningMessage;
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;
@@ -198,6 +213,7 @@
private async Task SubmitAsync()
{
_errorMessage = null;
_warningMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
@@ -244,6 +260,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;
_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).";
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 +364,43 @@
}
}
// 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);
}
}
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
// 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,10 @@
Disabled="Disabled" />
break;
case ReleaseMedium.Session:
<SessionFields />
<SessionFields HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
break;
case ReleaseMedium.Mix:
<MixFields />
@@ -42,5 +46,13 @@
[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; }
// 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; }
}
@@ -1,10 +1,68 @@
@* 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. *@
<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>
@using Microsoft.AspNetCore.Components.Forms
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
file picker is shown — the file is held by the parent and POSTed after the release is created.
When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
a dead/inert control on edit forms that do not wire the hero callbacks. *@
@if (AllowHeroUpload)
{
<MudItem xs="12">
<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>
}
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 {
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { 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) =>
HeroImageFileChanged.InvokeAsync(e.File);
private Task ClearHeroFile() =>
HeroImageFileChanged.InvokeAsync(null);
}