CMS Phase 9 Wave 3: Release Archive tab, medium selector, Session/Mix browsers
Renames Genre tab to Release Archive with switch-free medium card group (Enum.GetValues-driven). Adds MediumFields single dispatch + CutFields/SessionFields/ MixFields per-medium sections embedded by all five upload/edit forms. BatchUpload enforces single-track invariant for Session/Mix. Adds CmsSessionBrowser (hero-image upload) and CmsMixBrowser (waveform status + per-row Generate trigger). ICmsReleaseService/CmsReleaseService wraps api/release endpoints. Note: medium selector is forward-compat only — API write path pending.
This commit is contained in:
@@ -22,15 +22,6 @@
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseType" Value="ReleaseType" ValueChanged="@((ReleaseType v) => ReleaseTypeChanged.InvokeAsync(v))"
|
||||
Label="Release Type" Variant="Variant.Outlined" Disabled="Disabled">
|
||||
@foreach (var rt in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@@ -70,6 +61,12 @@
|
||||
</MudField>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MediumFields @bind-Medium="MediumBinding"
|
||||
@bind-ReleaseType="ReleaseTypeBinding"
|
||||
Disabled="Disabled" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
@@ -83,6 +80,8 @@
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
|
||||
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
|
||||
|
||||
@@ -98,6 +97,20 @@
|
||||
? null
|
||||
: $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}";
|
||||
|
||||
// MediumFields uses two-way @bind; bridge its bindings to this component's own
|
||||
// parameter/EventCallback pairs so the parent form stays the single owner of the values.
|
||||
private ReleaseMedium MediumBinding
|
||||
{
|
||||
get => Medium;
|
||||
set => MediumChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private ReleaseType ReleaseTypeBinding
|
||||
{
|
||||
get => ReleaseType;
|
||||
set => ReleaseTypeChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
|
||||
SelectedImageFileChanged.InvokeAsync(e.File);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
@bind-Genre="_genre"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
MediumChanged="OnMediumChanged"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
ExistingImagePath="_existingImagePath"
|
||||
Disabled="_saving" />
|
||||
@@ -53,6 +55,9 @@
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
@* TODO: When medium write path lands, collapse to single-track slot here for Session/Mix
|
||||
(matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard). Until then,
|
||||
BatchEdit's track list is unrestricted because _medium is read-only on the edit form. *@
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_saving"
|
||||
@@ -124,6 +129,12 @@
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
|
||||
// The medium selector drives ReleaseType visibility. NOTE: the metadata update endpoint
|
||||
// (PUT api/track/meta) has no medium field, so a medium change is not persisted on save today —
|
||||
// the selector reflects/adjusts local form shape only. Persisting medium-on-edit is an API change.
|
||||
private void OnMediumChanged(ReleaseMedium medium) => _medium = medium;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -155,6 +166,7 @@
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_medium = release?.Medium ?? ReleaseMedium.Cut;
|
||||
_existingImagePath = release?.ImagePath;
|
||||
|
||||
_tracks = tracks.Select(t => new BatchRowModel
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -22,28 +23,51 @@
|
||||
@bind-Genre="_genre"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
MediumChanged="OnMediumChanged"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
Disabled="_uploading" />
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_uploading"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
@if (_medium == ReleaseMedium.Cut)
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_uploading"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_uploading"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_uploading"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.subtitle1">Track</MudText>
|
||||
<InputFile OnChange="HandleSingleWavSelected" accept=".wav,audio/wav,audio/x-wav" disabled="@_uploading" />
|
||||
@if (_tracks.Count > 0)
|
||||
{
|
||||
<MudTextField @bind-Value="_tracks[0].TrackName"
|
||||
Label="Track Name"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="_uploading" />
|
||||
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
@@ -92,6 +116,39 @@
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
|
||||
// Switching to a single-track medium (Session/Mix) collapses any multi-track selection to the
|
||||
// first row so the single-track invariant holds before submit. Switching back to Cut keeps it.
|
||||
private void OnMediumChanged(ReleaseMedium medium)
|
||||
{
|
||||
_medium = medium;
|
||||
if (medium != ReleaseMedium.Cut && _tracks.Count > 1)
|
||||
{
|
||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-track WAV picker for Session/Mix: replaces the one row rather than appending.
|
||||
private void HandleSingleWavSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_errorMessage = null;
|
||||
var file = e.File;
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
_tracks.Clear();
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
@@ -237,7 +294,8 @@
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
trackNumber,
|
||||
_medium);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
@@ -272,6 +330,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// the Mixes browser's per-row Generate action.
|
||||
if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId)
|
||||
{
|
||||
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
|
||||
if (!waveformResult.Success)
|
||||
{
|
||||
Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')",
|
||||
mixReleaseId, row.TrackName);
|
||||
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
@page "/tracks/mixes"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsMixBrowser> Logger
|
||||
|
||||
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks/archive"
|
||||
Class="mb-4">
|
||||
Back to Release Archive
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No mixes found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="MixRow" Items="_rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%;">Cover</MudTh>
|
||||
<MudTh>Mix</MudTh>
|
||||
<MudTh>Artist</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Cover">
|
||||
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Mix">@context.Release.Title</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
||||
<MudTd DataLabel="Waveform">
|
||||
@if (context.HasWaveform)
|
||||
{
|
||||
<MudTooltip Text="Waveform generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="No waveform — incomplete">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@context.IsGenerating"
|
||||
OnClick="@(() => GenerateWaveformAsync(context))">
|
||||
@if (context.IsGenerating)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(context.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private List<MixRow> _rows = new();
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
// Mixes are single-track releases; a single generous page covers the CMS catalogue.
|
||||
var result = await CmsReleaseService.GetPagedAsync(
|
||||
ReleaseMedium.Mix, page: 1, pageSize: 100,
|
||||
sortColumn: "Title", sortDescending: false);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_rows = result.Value.Items
|
||||
.Select(r => new MixRow
|
||||
{
|
||||
Release = r,
|
||||
HasWaveform = !string.IsNullOrEmpty(r.MixMetadata?.WaveformEntryKey)
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load mixes: {error}", Severity.Error);
|
||||
_rows = new List<MixRow>();
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private static string ThumbUrl(string entryKey) =>
|
||||
$"/api/image/{Uri.EscapeDataString(entryKey)}";
|
||||
|
||||
private async Task GenerateWaveformAsync(MixRow row)
|
||||
{
|
||||
row.IsGenerating = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsReleaseService.GenerateMixWaveformAsync(row.Release.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
// Optimistic update: the trigger succeeded, so the waveform is stored. Unlike SessionBrowser's
|
||||
// re-fetch (which retrieves the server-generated HeroImageEntryKey), there is nothing to reflect
|
||||
// back here — HasWaveform is derived from WaveformEntryKey being non-null, which we know is now set.
|
||||
row.HasWaveform = true;
|
||||
Snackbar.Add($"Generated waveform for '{row.Release.Title}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for release {ReleaseId}", row.Release.Id);
|
||||
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
row.IsGenerating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MixRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
public bool HasWaveform { get; set; }
|
||||
public bool IsGenerating { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Scoped duplicate of the album-browser thumb idiom. Blazor CSS isolation is per-component, so the
|
||||
class defined in CmsAlbumBrowser.razor.css does not reach this component's markup — a small,
|
||||
intentional duplication rather than promoting a two-rule block to global app.css. */
|
||||
.cms-album-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-album-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
@page "/tracks/sessions"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsSessionBrowser> Logger
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks/archive"
|
||||
Class="mb-4">
|
||||
Back to Release Archive
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No sessions found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="SessionRow" Items="_rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%;">Cover</MudTh>
|
||||
<MudTh Style="width: 1%;">Hero</MudTh>
|
||||
<MudTh>Session</MudTh>
|
||||
<MudTh>Artist</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Cover">
|
||||
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Hero">
|
||||
@if (context.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Session">@context.Release.Title</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(context, file))"
|
||||
Disabled="@context.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@context.IsUploading">
|
||||
@if (context.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(context.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private List<SessionRow> _rows = new();
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
// Sessions are single-track releases; a single generous page covers the CMS catalogue (same
|
||||
// small-catalogue assumption the album browser makes).
|
||||
var result = await CmsReleaseService.GetPagedAsync(
|
||||
ReleaseMedium.Session, page: 1, pageSize: 100,
|
||||
sortColumn: "Title", sortDescending: false);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_rows = result.Value.Items
|
||||
.Select(r => new SessionRow
|
||||
{
|
||||
Release = r,
|
||||
HeroImageEntryKey = r.SessionMetadata?.HeroImageEntryKey
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load sessions: {error}", Severity.Error);
|
||||
_rows = new List<SessionRow>();
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private static string ThumbUrl(string entryKey) =>
|
||||
$"/api/image/{Uri.EscapeDataString(entryKey)}";
|
||||
|
||||
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
|
||||
{
|
||||
if (file is null) return;
|
||||
row.IsUploading = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var result = await CmsReleaseService.UploadSessionHeroImageAsync(
|
||||
row.Release.Id, stream, file.Name, file.ContentType);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// The endpoint returns no payload; the entry key is server-generated. Re-fetch the
|
||||
// release so the hero thumbnail reflects the new key without guessing it.
|
||||
var refreshed = await CmsReleaseService.GetByIdAsync(row.Release.Id);
|
||||
if (refreshed.Success && refreshed.Value is { } release)
|
||||
{
|
||||
row.Release = release;
|
||||
row.HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey;
|
||||
}
|
||||
Snackbar.Add($"Hero image set for '{row.Release.Title}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Hero image upload failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Hero image upload failed for release {ReleaseId}", row.Release.Id);
|
||||
Snackbar.Add("Hero image upload failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
row.IsUploading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
public string? HeroImageEntryKey { get; set; }
|
||||
public bool IsUploading { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Scoped duplicate of the album-browser thumb idiom. Blazor CSS isolation is per-component, so the
|
||||
class defined in CmsAlbumBrowser.razor.css does not reach this component's markup — a small,
|
||||
intentional duplication rather than promoting a two-rule block to global app.css. */
|
||||
.cms-album-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-album-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@using DeepDrftModels.Enums
|
||||
|
||||
@* Cut-medium fields: the commercial release format. Plain explicit markup — no generics. *@
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseType"
|
||||
Value="ReleaseType"
|
||||
ValueChanged="@(v => ReleaseTypeChanged.InvokeAsync(v))"
|
||||
Label="Release Type"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled">
|
||||
@foreach (var rt in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
@code {
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
@using DeepDrftModels.Enums
|
||||
|
||||
@* 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
|
||||
medium is one new section component + one new switch arm here — nowhere else. *@
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseMedium"
|
||||
Value="Medium"
|
||||
ValueChanged="@(v => MediumChanged.InvokeAsync(v))"
|
||||
Label="Medium"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled">
|
||||
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseMedium" Value="medium">@medium</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
|
||||
@switch (Medium)
|
||||
{
|
||||
case ReleaseMedium.Cut:
|
||||
<CutFields ReleaseType="ReleaseType"
|
||||
ReleaseTypeChanged="ReleaseTypeChanged"
|
||||
Disabled="Disabled" />
|
||||
break;
|
||||
case ReleaseMedium.Session:
|
||||
<SessionFields />
|
||||
break;
|
||||
case ReleaseMedium.Mix:
|
||||
<MixFields />
|
||||
break;
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
|
||||
|
||||
// Cut-only — bound through to CutFields. Ignored for Session/Mix.
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@* Mix-medium fields. The high-res waveform is a server-side derived datum: the CMS fires a body-less
|
||||
trigger (POST api/release/{id}/mix/waveform) after the release exists, so generation is managed
|
||||
per-row in the Mixes browser, not at create time. On upload the trigger is fired automatically; this
|
||||
section states that contract and carries no input of its own. *@
|
||||
<MudItem xs="12">
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
|
||||
Mixes are single-track DJ releases. The high-resolution waveform is generated automatically
|
||||
after upload; regenerate it any time from the <strong>Release Archive → Mixes</strong> browser.
|
||||
</MudAlert>
|
||||
</MudItem>
|
||||
@@ -0,0 +1,37 @@
|
||||
@using DeepDrftModels.Enums
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@* Release Archive: one card per ReleaseMedium, driven off Enum.GetValues + a display-metadata table.
|
||||
No hardcoded three-arm switch in markup — adding a medium surfaces a new card automatically and only
|
||||
needs one new entry in MediumCards below. Card idiom mirrors CmsGenreBrowser (MudCard + swatch). *@
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
||||
{
|
||||
var info = MediumCards[medium];
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="1"
|
||||
Style="cursor: pointer;"
|
||||
@onclick="@(() => Navigation.NavigateTo(info.Route))">
|
||||
<div class="@($"cms-medium-swatch cms-medium-swatch--{info.SwatchModifier}")"></div>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6">@info.Label</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@info.Descriptor</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private sealed record MediumCardInfo(string Label, string Descriptor, string SwatchModifier, string Route);
|
||||
|
||||
// The one place medium → display + navigation target lives. A future medium adds one entry here;
|
||||
// the markup above is untouched. The enum→record dictionary is a switch, data-structured (§3.1).
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, MediumCardInfo> MediumCards =
|
||||
new Dictionary<ReleaseMedium, MediumCardInfo>
|
||||
{
|
||||
[ReleaseMedium.Cut] = new("Cuts", "Studio singles, EPs, and albums", "cut", "/tracks/albums"),
|
||||
[ReleaseMedium.Session] = new("Sessions", "Single-track live recordings", "session", "/tracks/sessions"),
|
||||
[ReleaseMedium.Mix] = new("Mixes", "Single-track DJ mixes", "mix", "/tracks/mixes"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.cms-medium-swatch {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cms-medium-swatch--cut {
|
||||
background-color: var(--mud-palette-primary-hover);
|
||||
}
|
||||
|
||||
.cms-medium-swatch--session {
|
||||
background-color: var(--mud-palette-secondary-hover);
|
||||
}
|
||||
|
||||
.cms-medium-swatch--mix {
|
||||
background-color: var(--mud-palette-tertiary-hover);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@* 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>
|
||||
@@ -63,14 +63,7 @@
|
||||
Label="Genre"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudSelect @bind-Value="_form.ReleaseType"
|
||||
Label="Release Type"
|
||||
Variant="Variant.Outlined">
|
||||
@foreach (var releaseType in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem Value="releaseType">@releaseType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
|
||||
|
||||
<MudNumericField @bind-Value="_form.TrackNumber"
|
||||
Label="Track Number"
|
||||
@@ -304,6 +297,10 @@
|
||||
public string? ImagePath { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
// Drives ReleaseType visibility via MediumFields. NOTE: not persisted — PUT api/track/meta has
|
||||
// no medium field, so a medium change on edit is form-shape only until the API grows one.
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
public static TrackEditForm From(TrackDto track) => new()
|
||||
@@ -317,6 +314,7 @@
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null,
|
||||
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
||||
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
|
||||
TrackNumber = track.TrackNumber
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/genres"
|
||||
@page "/tracks/archive"
|
||||
@using DeepDrftManager.Services
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@@ -44,7 +45,7 @@
|
||||
Class="mb-4">
|
||||
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Archive">Release Archive</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
@@ -57,8 +58,14 @@
|
||||
IsLoading="VM.AlbumsLoading"
|
||||
OnReleasesChanged="OnAlbumsChanged" />
|
||||
}
|
||||
else if (VM.Mode == BrowseMode.Archive)
|
||||
{
|
||||
<ReleaseArchiveBrowser />
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
|
||||
Reachable by direct URL; no longer in the toggle group. *@
|
||||
<CmsGenreBrowser Genres="VM.Genres"
|
||||
IsLoading="VM.GenresLoading"
|
||||
ExpandedGenre="@VM.ExpandedGenre"
|
||||
@@ -85,11 +92,11 @@
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var uri = NavigationManager.Uri;
|
||||
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase)
|
||||
? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase)
|
||||
? BrowseMode.Genres
|
||||
: BrowseMode.Tracks;
|
||||
var initial =
|
||||
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Archive
|
||||
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
|
||||
: BrowseMode.Tracks;
|
||||
await VM.SwitchModeAsync(initial);
|
||||
}
|
||||
|
||||
@@ -99,6 +106,7 @@
|
||||
var path = mode switch
|
||||
{
|
||||
BrowseMode.Albums => "/tracks/albums",
|
||||
BrowseMode.Archive => "/tracks/archive",
|
||||
BrowseMode.Genres => "/tracks/genres",
|
||||
_ => "/tracks"
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -32,6 +33,8 @@
|
||||
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
||||
|
||||
<MediumFields @bind-Medium="_medium" @bind-ReleaseType="_releaseType" Disabled="_isUploading" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (_selectedImageFile is { } selectedImage)
|
||||
@@ -104,6 +107,8 @@
|
||||
private string _album = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
private string? _errorMessage;
|
||||
private bool _isUploading;
|
||||
|
||||
@@ -205,11 +210,24 @@
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
_selectedFile.Name,
|
||||
createdByUserId,
|
||||
releaseType: ReleaseType.Single,
|
||||
trackNumber: 1);
|
||||
_releaseType,
|
||||
trackNumber: 1,
|
||||
_medium);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes
|
||||
// nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser.
|
||||
if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId)
|
||||
{
|
||||
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
|
||||
if (!waveformResult.Success)
|
||||
{
|
||||
Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId);
|
||||
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with a
|
||||
// follow-up metadata update — same two-step pattern TrackEdit uses.
|
||||
if (_imagePath is { } imgPath && result.Value is { } created)
|
||||
|
||||
Reference in New Issue
Block a user