feat(cms): retire legacy single-track forms; route single-track edit into BatchEdit (8.M)
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
@page "/tracks/album/{AlbumName}/edit"
|
@page "/tracks/album/{AlbumName}/edit"
|
||||||
|
@page "/tracks/{TrackId:long}/edit"
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@using DeepDrftManager.Services
|
@using DeepDrftManager.Services
|
||||||
@using DeepDrftModels.DTOs
|
@using DeepDrftModels.DTOs
|
||||||
@@ -111,8 +112,15 @@
|
|||||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||||
private const long MaxUploadBytes = 1_073_741_824L;
|
private const long MaxUploadBytes = 1_073_741_824L;
|
||||||
|
|
||||||
|
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
|
||||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
|
||||||
|
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
|
||||||
|
// the track they clicked rather than on the release with no row context. Null for the
|
||||||
|
// release-title route. The two routes are mutually exclusive — only one segment binds.
|
||||||
|
[Parameter] public long? TrackId { get; set; }
|
||||||
|
|
||||||
private List<BatchRowModel> _tracks = new();
|
private List<BatchRowModel> _tracks = new();
|
||||||
private int _selectedIndex = -1;
|
private int _selectedIndex = -1;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
@@ -154,12 +162,35 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
|
||||||
|
// then fall through to the shared release-load path below. The clicked track's id is held
|
||||||
|
// for row pre-selection once the list is built.
|
||||||
|
var albumName = AlbumName;
|
||||||
|
if (TrackId is { } trackId)
|
||||||
|
{
|
||||||
|
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
|
||||||
|
if (!trackResult.Success || trackResult.Value is not { } track)
|
||||||
|
{
|
||||||
|
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
albumName = track.Release?.Title;
|
||||||
|
if (string.IsNullOrEmpty(albumName))
|
||||||
|
{
|
||||||
|
_loadError = "This track has no parent release to edit.";
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A single page of 100 covers the full release (albums are small — same assumption as
|
// A single page of 100 covers the full release (albums are small — same assumption as
|
||||||
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
||||||
var result = await CmsTrackService.GetPagedAsync(
|
var result = await CmsTrackService.GetPagedAsync(
|
||||||
page: 1, pageSize: 100,
|
page: 1, pageSize: 100,
|
||||||
sortColumn: "TrackNumber", sortDescending: false,
|
sortColumn: "TrackNumber", sortDescending: false,
|
||||||
album: AlbumName);
|
album: albumName);
|
||||||
|
|
||||||
if (!result.Success || result.Value is null)
|
if (!result.Success || result.Value is null)
|
||||||
{
|
{
|
||||||
@@ -171,13 +202,13 @@
|
|||||||
var tracks = result.Value.Items.ToList();
|
var tracks = result.Value.Items.ToList();
|
||||||
if (tracks.Count == 0)
|
if (tracks.Count == 0)
|
||||||
{
|
{
|
||||||
_loadError = $"No tracks found for release '{AlbumName}'.";
|
_loadError = $"No tracks found for release '{albumName}'.";
|
||||||
_loading = false;
|
_loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var release = tracks[0].Release;
|
var release = tracks[0].Release;
|
||||||
_albumName = AlbumName;
|
_albumName = albumName;
|
||||||
_artist = release?.Artist ?? string.Empty;
|
_artist = release?.Artist ?? string.Empty;
|
||||||
_genre = release?.Genre ?? string.Empty;
|
_genre = release?.Genre ?? string.Empty;
|
||||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||||
@@ -203,10 +234,24 @@
|
|||||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
|
||||||
|
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
|
||||||
|
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
|
||||||
|
_selectedIndex = ResolveInitialSelection();
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int ResolveInitialSelection()
|
||||||
|
{
|
||||||
|
if (_tracks.Count == 0) return -1;
|
||||||
|
if (TrackId is { } trackId)
|
||||||
|
{
|
||||||
|
var addressed = _tracks.FindIndex(t => t.Id == trackId);
|
||||||
|
if (addressed >= 0) return addressed;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||||
{
|
{
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
@@ -471,7 +516,7 @@
|
|||||||
|
|
||||||
if (!linkResult.Success)
|
if (!linkResult.Success)
|
||||||
{
|
{
|
||||||
// Non-blocking: track persisted; cover can be linked via TrackEdit.
|
// Non-blocking: track persisted; cover can be re-linked by re-editing.
|
||||||
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||||
row.TrackName, uploadResult.Value.Id);
|
row.TrackName, uploadResult.Value.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,7 +360,7 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||||
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
|
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
||||||
if (_imagePath is { } imgPath)
|
if (_imagePath is { } imgPath)
|
||||||
{
|
{
|
||||||
var linkResult = await CmsTrackService.UpdateAsync(
|
var linkResult = await CmsTrackService.UpdateAsync(
|
||||||
@@ -377,7 +377,7 @@
|
|||||||
|
|
||||||
if (!linkResult.Success)
|
if (!linkResult.Success)
|
||||||
{
|
{
|
||||||
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
|
// Non-blocking: track is persisted; cover art can be re-linked by editing.
|
||||||
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
||||||
row.TrackName, result.Value.Id);
|
row.TrackName, result.Value.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Href="@($"/tracks/{context.Id}")" />
|
Href="@($"/tracks/{context.Id}/edit")" />
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
<MudTooltip Text="Delete">
|
<MudTooltip Text="Delete">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
|
@* 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.
|
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
|
When false (edit path: BatchEdit), the original guidance alert is rendered
|
||||||
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
|
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. *@
|
a dead/inert control on edit forms that do not wire the hero callbacks. *@
|
||||||
@if (AllowHeroUpload)
|
@if (AllowHeroUpload)
|
||||||
|
|||||||
@@ -1,322 +1,16 @@
|
|||||||
@page "/tracks/{Id:long}"
|
@page "/tracks/{Id:long}"
|
||||||
@using DeepDrftManager.Services
|
|
||||||
@using DeepDrftModels.Enums
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject ICmsTrackService CmsTrackService
|
@inject NavigationManager Navigation
|
||||||
@inject CmsTrackBrowserViewModel VM
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject IDialogService DialogService
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
@inject ILogger<TrackEdit> Logger
|
|
||||||
|
|
||||||
<PageTitle>Edit Track — DeepDrft CMS</PageTitle>
|
@* §8.M: the legacy single-track edit form is retired. Its edit responsibility is absorbed by
|
||||||
|
BatchEdit's track-addressed entry (/tracks/{id}/edit), which loads the parent release and
|
||||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
pre-selects the addressed track's row. This route is kept only as a redirect so any bookmarked
|
||||||
<MudButton Variant="Variant.Text"
|
/tracks/{id} lands on the live edit surface; CmsTrackGrid's per-row Edit targets the new route
|
||||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
directly. *@
|
||||||
Href="/tracks"
|
|
||||||
Class="mb-4">
|
|
||||||
Back to tracks
|
|
||||||
</MudButton>
|
|
||||||
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Indeterminate="true" />
|
|
||||||
}
|
|
||||||
else if (_track is null)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Warning">
|
|
||||||
Track not found.
|
|
||||||
</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Track</MudText>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-6" Elevation="2">
|
|
||||||
<MudStack Spacing="4">
|
|
||||||
<MudField Label="Entry Key" Variant="Variant.Outlined" InnerPadding="false">
|
|
||||||
<MudText Typo="Typo.body1" Style="font-family: monospace;">@_track.EntryKey</MudText>
|
|
||||||
<MudText Typo="Typo.caption" Color="Color.Default">
|
|
||||||
Vault reference — not editable.
|
|
||||||
</MudText>
|
|
||||||
</MudField>
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="_form.TrackName"
|
|
||||||
Label="Track Name"
|
|
||||||
Required="true"
|
|
||||||
RequiredError="Track name is required"
|
|
||||||
Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="_form.Artist"
|
|
||||||
Label="Artist"
|
|
||||||
Required="true"
|
|
||||||
RequiredError="Artist is required"
|
|
||||||
Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="_form.Album"
|
|
||||||
Label="Album"
|
|
||||||
Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="_form.Genre"
|
|
||||||
Label="Genre"
|
|
||||||
Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
<MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
|
|
||||||
|
|
||||||
<MudNumericField @bind-Value="_form.TrackNumber"
|
|
||||||
Label="Track Number"
|
|
||||||
Min="1"
|
|
||||||
Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
|
||||||
<MudStack Spacing="3">
|
|
||||||
@if (ImagePreviewUrl is { } previewUrl)
|
|
||||||
{
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
|
||||||
<MudImage Src="@previewUrl"
|
|
||||||
Alt="Cover art preview"
|
|
||||||
Elevation="1"
|
|
||||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
|
||||||
Color="Color.Error"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="_busy"
|
|
||||||
OnClick="ClearImage"
|
|
||||||
aria-label="Clear cover art" />
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
else if (_selectedImageFile is not null)
|
|
||||||
{
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
|
||||||
Color="Color.Error"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="_busy"
|
|
||||||
OnClick="ClearImage"
|
|
||||||
aria-label="Cancel image selection" />
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
|
|
||||||
}
|
|
||||||
|
|
||||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
|
|
||||||
@if (_selectedImageFile is { } selected)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
</MudField>
|
|
||||||
|
|
||||||
<MudDatePicker @bind-Date="_form.ReleaseDate"
|
|
||||||
Label="Release Date"
|
|
||||||
DateFormat="yyyy-MM-dd"
|
|
||||||
Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween">
|
|
||||||
<MudButton Variant="Variant.Filled"
|
|
||||||
Color="Color.Error"
|
|
||||||
StartIcon="@Icons.Material.Filled.Delete"
|
|
||||||
Disabled="_busy"
|
|
||||||
OnClick="ConfirmDelete">
|
|
||||||
Delete
|
|
||||||
</MudButton>
|
|
||||||
|
|
||||||
<MudButton Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Save"
|
|
||||||
Disabled="_busy || !CanSave"
|
|
||||||
OnClick="SaveAsync">
|
|
||||||
Save Changes
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
|
||||||
</MudContainer>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public long Id { get; set; }
|
[Parameter] public long Id { get; set; }
|
||||||
|
|
||||||
private TrackDto? _track;
|
protected override void OnInitialized() =>
|
||||||
private TrackEditForm _form = new();
|
Navigation.NavigateTo($"/tracks/{Id}/edit", replace: true);
|
||||||
private bool _loading = true;
|
|
||||||
private bool _busy;
|
|
||||||
private IBrowserFile? _selectedImageFile;
|
|
||||||
|
|
||||||
private bool CanSave =>
|
|
||||||
!string.IsNullOrWhiteSpace(_form.TrackName)
|
|
||||||
&& !string.IsNullOrWhiteSpace(_form.Artist);
|
|
||||||
|
|
||||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
|
||||||
private string? ImagePreviewUrl =>
|
|
||||||
string.IsNullOrEmpty(_form.ImagePath)
|
|
||||||
? null
|
|
||||||
: $"/api/image/{Uri.EscapeDataString(_form.ImagePath)}";
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAsync()
|
|
||||||
{
|
|
||||||
_loading = true;
|
|
||||||
var result = await CmsTrackService.GetByIdAsync(Id);
|
|
||||||
_track = result.Success ? result.Value : null;
|
|
||||||
if (_track is not null)
|
|
||||||
{
|
|
||||||
_form = TrackEditForm.From(_track);
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveAsync()
|
|
||||||
{
|
|
||||||
if (_track is null || !CanSave) return;
|
|
||||||
|
|
||||||
_busy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Upload any newly picked cover art first; abort the save if it fails so we never
|
|
||||||
// persist metadata pointing at an image that was never stored.
|
|
||||||
if (_selectedImageFile is { } file)
|
|
||||||
{
|
|
||||||
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
|
|
||||||
var uploadResult = await CmsTrackService.UploadImageAsync(
|
|
||||||
imageStream, file.Name, file.ContentType);
|
|
||||||
if (!uploadResult.Success)
|
|
||||||
{
|
|
||||||
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_form.ImagePath = uploadResult.Value;
|
|
||||||
_selectedImageFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
|
|
||||||
// loads the authoritative row and applies these fields. imagePath is tri-state: an
|
|
||||||
// explicit empty string clears the link, a value sets it.
|
|
||||||
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
|
|
||||||
var updated = await CmsTrackService.UpdateAsync(
|
|
||||||
Id, _form.TrackName, _form.Artist,
|
|
||||||
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
|
||||||
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
|
||||||
releaseDate,
|
|
||||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
|
|
||||||
_form.ReleaseType,
|
|
||||||
_form.Medium,
|
|
||||||
_form.TrackNumber);
|
|
||||||
if (updated.Success)
|
|
||||||
{
|
|
||||||
// Album/genre browse lists derive from this track's metadata; drop their cache so
|
|
||||||
// the /tracks browser re-fetches fresh data on next mode switch.
|
|
||||||
VM.Invalidate();
|
|
||||||
Snackbar.Add("Track updated.", Severity.Success);
|
|
||||||
await LoadAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
Snackbar.Add($"Save failed: {error}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Save failed for track {TrackId}", Id);
|
|
||||||
Snackbar.Add("Save failed — please try again.", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
|
||||||
{
|
|
||||||
_selectedImageFile = e.File;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearImage()
|
|
||||||
{
|
|
||||||
_form.ImagePath = null;
|
|
||||||
_selectedImageFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ConfirmDelete()
|
|
||||||
{
|
|
||||||
if (_track is null) return;
|
|
||||||
|
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
|
||||||
"Delete track",
|
|
||||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
|
|
||||||
yesText: "Delete",
|
|
||||||
cancelText: "Cancel");
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
_busy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
|
||||||
if (result.Success)
|
|
||||||
{
|
|
||||||
// Deleting a track can empty or alter a release; drop the browse cache so the
|
|
||||||
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
|
|
||||||
VM.Invalidate();
|
|
||||||
Snackbar.Add("Track deleted.", Severity.Success);
|
|
||||||
Nav.NavigateTo("/tracks");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Delete failed for track {TrackId}", Id);
|
|
||||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class TrackEditForm
|
|
||||||
{
|
|
||||||
public string TrackName { get; set; } = string.Empty;
|
|
||||||
public string Artist { get; set; } = string.Empty;
|
|
||||||
public string? Album { get; set; }
|
|
||||||
public string? Genre { get; set; }
|
|
||||||
public string? ImagePath { get; set; }
|
|
||||||
public DateTime? ReleaseDate { get; set; }
|
|
||||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
|
||||||
|
|
||||||
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
|
|
||||||
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
|
|
||||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
|
||||||
public int TrackNumber { get; set; } = 1;
|
|
||||||
|
|
||||||
public static TrackEditForm From(TrackDto track) => new()
|
|
||||||
{
|
|
||||||
TrackName = track.TrackName,
|
|
||||||
Artist = track.Release?.Artist ?? string.Empty,
|
|
||||||
Album = track.Release?.Title,
|
|
||||||
Genre = track.Release?.Genre,
|
|
||||||
ImagePath = track.Release?.ImagePath,
|
|
||||||
ReleaseDate = track.Release?.ReleaseDate is { } d
|
|
||||||
? d.ToDateTime(TimeOnly.MinValue)
|
|
||||||
: null,
|
|
||||||
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
|
||||||
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
|
|
||||||
TrackNumber = track.TrackNumber
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,287 +1,12 @@
|
|||||||
@page "/tracks/new"
|
@page "/tracks/new"
|
||||||
@using System.Security.Claims
|
|
||||||
@using DeepDrftManager.Services
|
|
||||||
@using DeepDrftModels.Enums
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
@inject ICmsTrackService CmsTrackService
|
|
||||||
@inject ICmsReleaseService CmsReleaseService
|
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject ILogger<TrackNew> Logger
|
|
||||||
@inject CmsTrackBrowserViewModel VM
|
|
||||||
|
|
||||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
@* §8.M: the legacy single-track add form is retired. Its add responsibility is absorbed by
|
||||||
|
BatchUpload's single-track branch. This route is kept only as a redirect so any bookmarked
|
||||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
/tracks/new lands on the live upload surface; there are no in-app callers. *@
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
|
|
||||||
|
|
||||||
<MudPaper Class="pa-6" Elevation="2">
|
|
||||||
<MudStack Spacing="4">
|
|
||||||
<MudText Typo="Typo.subtitle1">WAV file</MudText>
|
|
||||||
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
|
|
||||||
@if (_selectedFile is not null)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2">
|
|
||||||
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
|
|
||||||
</MudText>
|
|
||||||
}
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
|
|
||||||
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
|
||||||
<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)
|
|
||||||
{
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
|
||||||
Color="Color.Error"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="_isUploading"
|
|
||||||
OnClick="ClearImage"
|
|
||||||
aria-label="Cancel image selection" />
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
|
||||||
}
|
|
||||||
|
|
||||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_isUploading" />
|
|
||||||
@if (_selectedImageFile is not null)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption">Will upload on save.</MudText>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
</MudField>
|
|
||||||
|
|
||||||
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
|
|
||||||
<MudButton Variant="Variant.Text"
|
|
||||||
OnClick="Cancel"
|
|
||||||
Disabled="_isUploading">
|
|
||||||
Cancel
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
|
||||||
OnClick="SubmitAsync"
|
|
||||||
Disabled="_isUploading">
|
|
||||||
@if (_isUploading)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
|
||||||
<text>Uploading…</text>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<text>Upload</text>
|
|
||||||
}
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
</MudContainer>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
protected override void OnInitialized() =>
|
||||||
// streaming path means the limit caps the request, not in-memory buffering.
|
Navigation.NavigateTo("/tracks/upload", replace: true);
|
||||||
private const long MaxUploadBytes = 1_073_741_824L;
|
|
||||||
|
|
||||||
private IBrowserFile? _selectedFile;
|
|
||||||
private IBrowserFile? _selectedImageFile;
|
|
||||||
private string? _imagePath;
|
|
||||||
private string _trackName = string.Empty;
|
|
||||||
private string _artist = string.Empty;
|
|
||||||
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;
|
|
||||||
|
|
||||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
|
||||||
{
|
|
||||||
_selectedFile = e.File;
|
|
||||||
_errorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
|
||||||
{
|
|
||||||
_selectedImageFile = e.File;
|
|
||||||
_imagePath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearImage()
|
|
||||||
{
|
|
||||||
_selectedImageFile = null;
|
|
||||||
_imagePath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
|
||||||
{
|
|
||||||
_errorMessage = null;
|
|
||||||
|
|
||||||
if (_selectedFile is null)
|
|
||||||
{
|
|
||||||
_errorMessage = "Please select a WAV file.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_errorMessage = "Selected file must be a .wav file.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_trackName))
|
|
||||||
{
|
|
||||||
_errorMessage = "Track Name is required.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_artist))
|
|
||||||
{
|
|
||||||
_errorMessage = "Artist is required.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
|
||||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
|
||||||
{
|
|
||||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
|
||||||
{
|
|
||||||
// The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or
|
|
||||||
// unparseable id here is a configuration bug, not normal client state.
|
|
||||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
|
||||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isUploading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Upload any selected cover art first; abort the submit if it fails so we never
|
|
||||||
// create a track expecting an image that was never stored in the vault.
|
|
||||||
if (_selectedImageFile is { } imgFile)
|
|
||||||
{
|
|
||||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
|
||||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
|
||||||
if (!imgResult.Success)
|
|
||||||
{
|
|
||||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
_errorMessage = $"Image upload failed: {imgError}";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_imagePath = imgResult.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
|
||||||
// service wraps it in StreamContent so the whole file is never materialised in
|
|
||||||
// memory before DeepDrftAPI receives it.
|
|
||||||
await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes);
|
|
||||||
|
|
||||||
var result = await CmsTrackService.UploadTrackAsync(
|
|
||||||
fileStream,
|
|
||||||
_selectedFile.Name,
|
|
||||||
_selectedFile.ContentType,
|
|
||||||
_trackName,
|
|
||||||
_artist,
|
|
||||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
|
||||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
|
||||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
|
||||||
_selectedFile.Name,
|
|
||||||
createdByUserId,
|
|
||||||
_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)
|
|
||||||
{
|
|
||||||
var linkResult = await CmsTrackService.UpdateAsync(
|
|
||||||
created.Id,
|
|
||||||
_trackName,
|
|
||||||
_artist,
|
|
||||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
|
||||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
|
||||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
|
||||||
imgPath);
|
|
||||||
if (!linkResult.Success)
|
|
||||||
{
|
|
||||||
// Track was created; image is in the vault but unlinked. Non-blocking —
|
|
||||||
// the user can attach it via Edit.
|
|
||||||
Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
|
||||||
VM.Invalidate();
|
|
||||||
Navigation.NavigateTo("/tracks");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
||||||
_errorMessage = $"Upload failed: {error}";
|
|
||||||
Logger.LogWarning("CMS upload rejected: {Error}", error);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Upload failed in TrackNew");
|
|
||||||
_errorMessage = "Upload failed. Please try again.";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isUploading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Cancel()
|
|
||||||
{
|
|
||||||
Navigation.NavigateTo("/tracks");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatBytes(long bytes)
|
|
||||||
{
|
|
||||||
const long KB = 1024;
|
|
||||||
const long MB = KB * 1024;
|
|
||||||
const long GB = MB * 1024;
|
|
||||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
|
||||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
|
||||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
|
||||||
return $"{bytes} bytes";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user