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" <MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding" @bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" /> Disabled="Disabled" />
</MudPaper> </MudPaper>
@@ -85,6 +88,15 @@
[Parameter] public IBrowserFile? SelectedImageFile { get; set; } [Parameter] public IBrowserFile? SelectedImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { 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. // 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; }
@@ -26,6 +26,8 @@
Medium="_medium" Medium="_medium"
MediumChanged="OnMediumChanged" MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile" @bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile"
AllowHeroUpload="true"
Disabled="_uploading" /> Disabled="_uploading" />
@if (_medium == ReleaseMedium.Cut) @if (_medium == ReleaseMedium.Cut)
@@ -71,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"))"
@@ -104,10 +111,18 @@
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;
// 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 _albumName = string.Empty;
private string _artist = string.Empty; private string _artist = string.Empty;
private string _genre = string.Empty; private string _genre = string.Empty;
@@ -198,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))
{ {
@@ -244,6 +260,18 @@
return; 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 — // 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. // no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0) 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 // 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.
// Non-blocking: the track is persisted; a failed trigger is recoverable from // Non-blocking: the track is persisted; a failed trigger is recoverable from
@@ -1,4 +1,5 @@
@using DeepDrftModels.Enums @using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@* The single dispatch point for medium-conditional form fields. All five upload/edit forms embed this @* 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 one component; the @switch below is the ONLY place medium-specific form shape is decided. Adding a
@@ -26,7 +27,10 @@
Disabled="Disabled" /> Disabled="Disabled" />
break; break;
case ReleaseMedium.Session: case ReleaseMedium.Session:
<SessionFields /> <SessionFields HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
break; break;
case ReleaseMedium.Mix: case ReleaseMedium.Mix:
<MixFields /> <MixFields />
@@ -42,5 +46,13 @@
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; } [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; } [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) @using Microsoft.AspNetCore.Components.Forms
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 @* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
the hero image is managed; it carries no input of its own. *@ file picker is shown — the file is held by the parent and POSTed after the release is created.
<MudItem xs="12"> When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined"> instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
Sessions are single-track live releases. After upload, set the hero image from the a dead/inert control on edit forms that do not wire the hero callbacks. *@
<strong>Release Archive → Sessions</strong> browser. @if (AllowHeroUpload)
</MudAlert> {
</MudItem> <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);
}