From 47018045949c45fbb6ce173b8e42e2d34fd06ee6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 13 Jun 2026 20:46:46 -0400 Subject: [PATCH 1/2] 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. --- .../Pages/Tracks/AlbumHeaderFields.razor | 7 +++ .../Components/Pages/Tracks/BatchUpload.razor | 47 ++++++++++++++++ .../Pages/Tracks/MediumFields.razor | 9 ++- .../Pages/Tracks/SessionFields.razor | 55 ++++++++++++++++--- 4 files changed, 110 insertions(+), 8 deletions(-) diff --git a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor index 89c70e9..9ca6ca1 100644 --- a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor @@ -66,6 +66,8 @@ @@ -85,6 +87,11 @@ [Parameter] public IBrowserFile? SelectedImageFile { get; set; } [Parameter] public EventCallback 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 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; } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index 76ce17d..a091edf 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -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 diff --git a/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor b/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor index 45a5cde..0c66397 100644 --- a/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor @@ -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: - + break; case ReleaseMedium.Mix: @@ -42,5 +45,9 @@ [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; [Parameter] public EventCallback 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 HeroImageFileChanged { get; set; } + [Parameter] public bool Disabled { get; set; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor b/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor index 227dc7c..e62e24a 100644 --- a/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor @@ -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. *@ - - Sessions are single-track live releases. After upload, set the hero image from the - Release Archive → Sessions browser. - + + + + Sessions are single-track live releases. The hero image is the session's primary visual identity. + + + @if (HeroImageFile is { } selectedHero) + { + + Selected: @selectedHero.Name + + + } + else + { + No hero image — optional, but recommended. + } + + + @if (HeroImageFile is not null) + { + Will upload on submit. + } + + + +@code { + [Parameter] public IBrowserFile? HeroImageFile { get; set; } + [Parameter] public EventCallback HeroImageFileChanged { get; set; } + [Parameter] public bool Disabled { get; set; } + + private Task HandleHeroFileSelected(InputFileChangeEventArgs e) => + HeroImageFileChanged.InvokeAsync(e.File); + + private Task ClearHeroFile() => + HeroImageFileChanged.InvokeAsync(null); +} From 62dd9d5c030ea34bcfbe4aad39551bfc0ade3e99 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 13 Jun 2026 20:55:34 -0400 Subject: [PATCH 2/2] 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. --- .../Pages/Tracks/AlbumHeaderFields.razor | 5 ++ .../Components/Pages/Tracks/BatchUpload.razor | 20 ++++- .../Pages/Tracks/MediumFields.razor | 5 ++ .../Pages/Tracks/SessionFields.razor | 87 +++++++++++-------- 4 files changed, 81 insertions(+), 36 deletions(-) diff --git a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor index 9ca6ca1..113c85d 100644 --- a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor @@ -68,6 +68,7 @@ @bind-ReleaseType="ReleaseTypeBinding" HeroImageFile="HeroImageFile" HeroImageFileChanged="HeroImageFileChanged" + AllowHeroUpload="AllowHeroUpload" Disabled="Disabled" /> @@ -92,6 +93,10 @@ [Parameter] public IBrowserFile? HeroImageFile { get; set; } [Parameter] public EventCallback 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; } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index a091edf..66ee1ea 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -27,6 +27,7 @@ MediumChanged="OnMediumChanged" @bind-SelectedImageFile="_selectedImageFile" @bind-HeroImageFile="_heroImageFile" + AllowHeroUpload="true" Disabled="_uploading" /> @if (_medium == ReleaseMedium.Cut) @@ -72,6 +73,11 @@ @_errorMessage } + @if (!string.IsNullOrEmpty(_warningMessage)) + { + @_warningMessage + } + break; case ReleaseMedium.Mix: @@ -49,5 +50,9 @@ [Parameter] public IBrowserFile? HeroImageFile { get; set; } [Parameter] public EventCallback 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; } } diff --git a/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor b/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor index e62e24a..cee4777 100644 --- a/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor @@ -1,48 +1,65 @@ @using Microsoft.AspNetCore.Components.Forms -@* Session-medium fields. The hero image is resource-addressed (POST api/release/{id}/session/hero-image) - 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. *@ - - - - - Sessions are single-track live releases. The hero image is the session's primary visual identity. - +@* 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) +{ + + + + + Sessions are single-track live releases. The hero image is the session's primary visual identity. + - @if (HeroImageFile is { } selectedHero) - { - - Selected: @selectedHero.Name - - - } - else - { - No hero image — optional, but recommended. - } + @if (HeroImageFile is { } selectedHero) + { + + Selected: @selectedHero.Name + + + } + else + { + No hero image — optional, but recommended. + } - - @if (HeroImageFile is not null) - { - Will upload on submit. - } - - - + + @if (HeroImageFile is not null) + { + Will upload on submit. + } + + + +} +else +{ + + + Sessions are single-track live releases. After upload, set the hero image from the + Release Archive → Sessions browser. + + +} @code { [Parameter] public IBrowserFile? HeroImageFile { get; set; } [Parameter] public EventCallback 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);