Merge branch 'p8-w5c-batch-edit' into dev
This commit is contained in:
@@ -117,8 +117,9 @@ public class TrackManager
|
|||||||
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
||||||
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
||||||
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
||||||
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
||||||
_ => e => e.Id
|
"TrackNumber" => e => e.TrackNumber,
|
||||||
|
_ => e => e.Id
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
@using DeepDrftModels.Enums
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@inject IHttpClientFactory HttpClientFactory
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6 mb-4" Elevation="2">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))"
|
||||||
|
T="string" Label="Album Name" Required="true" RequiredError="Album Name is required"
|
||||||
|
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField Value="Artist" ValueChanged="@((string v) => ArtistChanged.InvokeAsync(v))"
|
||||||
|
T="string" Label="Artist" Required="true" RequiredError="Artist is required"
|
||||||
|
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField Value="Genre" ValueChanged="@((string v) => GenreChanged.InvokeAsync(v))"
|
||||||
|
T="string" Label="Genre" Variant="Variant.Outlined" Disabled="Disabled" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudTextField Value="ReleaseDate" ValueChanged="@((string v) => ReleaseDateChanged.InvokeAsync(v))"
|
||||||
|
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">
|
||||||
|
@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="Disabled"
|
||||||
|
OnClick="ClearSelectedFile"
|
||||||
|
aria-label="Cancel image selection" />
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
else if (ExistingImagePreviewUrl is { } previewUrl)
|
||||||
|
{
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudImage Src="@previewUrl"
|
||||||
|
Alt="Current cover art"
|
||||||
|
Elevation="1"
|
||||||
|
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Default">Current cover art.</MudText>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@Disabled" />
|
||||||
|
@if (SelectedImageFile is not null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudField>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> AlbumNameChanged { get; set; }
|
||||||
|
[Parameter] public string Artist { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||||
|
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||||
|
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||||
|
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||||
|
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||||
|
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
|
||||||
|
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { 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; }
|
||||||
|
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
|
||||||
|
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser hits
|
||||||
|
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses.
|
||||||
|
private string? ExistingImagePreviewUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ExistingImagePath)) return null;
|
||||||
|
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||||
|
return baseAddress is null
|
||||||
|
? null
|
||||||
|
: new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(ExistingImagePath)}").ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
|
||||||
|
SelectedImageFileChanged.InvokeAsync(e.File);
|
||||||
|
|
||||||
|
private Task ClearSelectedFile() =>
|
||||||
|
SelectedImageFileChanged.InvokeAsync(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
@page "/tracks/album/{AlbumName}/edit"
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using DeepDrftManager.Services
|
||||||
|
@using DeepDrftModels.DTOs
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
@inject ICmsTrackService CmsTrackService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ILogger<BatchEdit> Logger
|
||||||
|
|
||||||
|
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
|
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||||
|
}
|
||||||
|
else if (_loadError is { } loadError)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Class="mt-4">@loadError</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||||
|
@bind-Artist="_artist"
|
||||||
|
@bind-Genre="_genre"
|
||||||
|
@bind-ReleaseDate="_releaseDate"
|
||||||
|
@bind-ReleaseType="_releaseType"
|
||||||
|
@bind-SelectedImageFile="_selectedImageFile"
|
||||||
|
ExistingImagePath="_existingImagePath"
|
||||||
|
Disabled="_saving" />
|
||||||
|
|
||||||
|
@if (_existingImagePath is not null && _selectedImageFile is null)
|
||||||
|
{
|
||||||
|
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-4">
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Error"
|
||||||
|
StartIcon="@Icons.Material.Filled.Delete"
|
||||||
|
Disabled="_saving"
|
||||||
|
OnClick="RemoveCover">
|
||||||
|
Remove cover
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<BatchTrackList Tracks="_tracks"
|
||||||
|
@bind-SelectedIndex="_selectedIndex"
|
||||||
|
Disabled="_saving"
|
||||||
|
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="_saving"
|
||||||
|
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
|
||||||
|
Disabled="_saving">
|
||||||
|
Cancel
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="SaveAsync"
|
||||||
|
Disabled="@(_saving || _tracks.Count == 0)">
|
||||||
|
@if (_saving)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||||
|
<text>Saving @_processedCount / @_tracks.Count…</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>Save Changes</text>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</MudContainer>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||||
|
private const long MaxUploadBytes = 1_073_741_824L;
|
||||||
|
|
||||||
|
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private List<BatchRowModel> _tracks = new();
|
||||||
|
private int _selectedIndex = -1;
|
||||||
|
private bool _loading = true;
|
||||||
|
private string? _loadError;
|
||||||
|
private bool _saving;
|
||||||
|
private int _processedCount;
|
||||||
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
private IBrowserFile? _selectedImageFile;
|
||||||
|
private string? _imagePath;
|
||||||
|
private string? _existingImagePath;
|
||||||
|
private bool _clearExistingImage;
|
||||||
|
|
||||||
|
private string _albumName = string.Empty;
|
||||||
|
private string _artist = string.Empty;
|
||||||
|
private string _genre = string.Empty;
|
||||||
|
private string _releaseDate = string.Empty;
|
||||||
|
private ReleaseType _releaseType = ReleaseType.Single;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// 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.
|
||||||
|
var result = await CmsTrackService.GetPagedAsync(
|
||||||
|
page: 1, pageSize: 100,
|
||||||
|
sortColumn: "TrackNumber", sortDescending: false,
|
||||||
|
album: AlbumName);
|
||||||
|
|
||||||
|
if (!result.Success || result.Value is null)
|
||||||
|
{
|
||||||
|
_loadError = result.Messages.FirstOrDefault()?.Message ?? "Failed to load release.";
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tracks = result.Value.Items.ToList();
|
||||||
|
if (tracks.Count == 0)
|
||||||
|
{
|
||||||
|
_loadError = $"No tracks found for release '{AlbumName}'.";
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var release = tracks[0].Release;
|
||||||
|
_albumName = AlbumName;
|
||||||
|
_artist = release?.Artist ?? string.Empty;
|
||||||
|
_genre = release?.Genre ?? string.Empty;
|
||||||
|
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||||
|
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||||
|
_existingImagePath = release?.ImagePath;
|
||||||
|
|
||||||
|
_tracks = tracks.Select(t => new BatchRowModel
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
EntryKey = t.EntryKey,
|
||||||
|
OriginalFileName = t.OriginalFileName,
|
||||||
|
TrackName = t.TrackName,
|
||||||
|
TrackNumber = t.TrackNumber,
|
||||||
|
WavFile = null,
|
||||||
|
Status = BatchRowStatus.Queued
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New rows carry no Id — they take the upload path on save.
|
||||||
|
_tracks.Add(new BatchRowModel
|
||||||
|
{
|
||||||
|
WavFile = file,
|
||||||
|
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||||
|
{
|
||||||
|
_selectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveUp(int i)
|
||||||
|
{
|
||||||
|
if (i == 0) return;
|
||||||
|
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||||
|
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||||
|
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveDown(int i)
|
||||||
|
{
|
||||||
|
if (i == _tracks.Count - 1) return;
|
||||||
|
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||||
|
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||||
|
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveRow(int index)
|
||||||
|
{
|
||||||
|
var row = _tracks[index];
|
||||||
|
if (row.Id.HasValue)
|
||||||
|
{
|
||||||
|
// Existing track — confirm before deleting.
|
||||||
|
var confirmed = await DialogService.ShowMessageBox(
|
||||||
|
"Remove track",
|
||||||
|
$"Remove '{row.TrackName}' from this release? This deletes the track permanently.",
|
||||||
|
yesText: "Remove", cancelText: "Cancel");
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
var result = await CmsTrackService.DeleteTrackAsync(row.Id.Value);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New track (not yet uploaded) or confirmed existing delete — remove from list.
|
||||||
|
_tracks.RemoveAt(index);
|
||||||
|
if (index < _selectedIndex) _selectedIndex--;
|
||||||
|
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveCover()
|
||||||
|
{
|
||||||
|
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
|
||||||
|
// the existing path here drops the preview in AlbumHeaderFields.
|
||||||
|
_clearExistingImage = true;
|
||||||
|
_existingImagePath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_albumName))
|
||||||
|
{
|
||||||
|
_errorMessage = "Album Name is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_artist))
|
||||||
|
{
|
||||||
|
_errorMessage = "Artist is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tracks.Count == 0)
|
||||||
|
{
|
||||||
|
_errorMessage = "A release must have at least one track.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||||
|
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||||
|
{
|
||||||
|
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New rows (no Id) need a WAV; existing rows keep their vault audio.
|
||||||
|
foreach (var t in _tracks)
|
||||||
|
{
|
||||||
|
if (!t.Id.HasValue && t.WavFile is null)
|
||||||
|
{
|
||||||
|
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||||
|
{
|
||||||
|
// [Authorize]/Admin-gated page — an unparseable id here is a configuration bug.
|
||||||
|
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||||
|
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateOnly? releaseDate = string.IsNullOrWhiteSpace(_releaseDate)
|
||||||
|
? null
|
||||||
|
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||||
|
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||||
|
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||||
|
|
||||||
|
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||||
|
_saving = true;
|
||||||
|
_processedCount = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Upload any newly picked cover art once; abort if it fails so we never point metadata
|
||||||
|
// at an image that was never stored.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tri-state cover for UpdateAsync: a freshly uploaded path sets it; an explicit clear
|
||||||
|
// sends ""; otherwise null leaves the existing cover untouched.
|
||||||
|
string? imagePathForUpdate =
|
||||||
|
_imagePath is { } newPath ? newPath
|
||||||
|
: _clearExistingImage ? ""
|
||||||
|
: null;
|
||||||
|
|
||||||
|
int succeeded = 0, failed = 0;
|
||||||
|
for (int i = 0; i < _tracks.Count; i++)
|
||||||
|
{
|
||||||
|
var row = _tracks[i];
|
||||||
|
|
||||||
|
if (row.Status == BatchRowStatus.Done)
|
||||||
|
{
|
||||||
|
_processedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||||
|
|
||||||
|
row.Status = BatchRowStatus.Uploading;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (row.Id.HasValue)
|
||||||
|
{
|
||||||
|
// Existing track — metadata-only update; audio stays in the vault.
|
||||||
|
var updateResult = await CmsTrackService.UpdateAsync(
|
||||||
|
row.Id.Value,
|
||||||
|
row.TrackName,
|
||||||
|
_artist,
|
||||||
|
album,
|
||||||
|
genre,
|
||||||
|
releaseDate,
|
||||||
|
imagePathForUpdate,
|
||||||
|
_releaseType,
|
||||||
|
trackNumber);
|
||||||
|
|
||||||
|
if (!updateResult.Success)
|
||||||
|
{
|
||||||
|
var error = updateResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
row.Status = BatchRowStatus.Failed;
|
||||||
|
row.ErrorMessage = error;
|
||||||
|
failed++;
|
||||||
|
Logger.LogWarning("Batch edit: update for '{TrackName}' (id={Id}) failed: {Error}",
|
||||||
|
row.TrackName, row.Id.Value, error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
row.Status = BatchRowStatus.Done;
|
||||||
|
succeeded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// New track — upload, then link cover art with a follow-up update (same
|
||||||
|
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
|
||||||
|
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||||
|
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
||||||
|
wavStream,
|
||||||
|
row.WavFile.Name,
|
||||||
|
row.WavFile.ContentType,
|
||||||
|
row.TrackName,
|
||||||
|
_artist,
|
||||||
|
album,
|
||||||
|
genre,
|
||||||
|
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||||
|
row.WavFile.Name,
|
||||||
|
createdByUserId,
|
||||||
|
_releaseType,
|
||||||
|
trackNumber);
|
||||||
|
|
||||||
|
if (!uploadResult.Success || uploadResult.Value is null)
|
||||||
|
{
|
||||||
|
var error = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
row.Status = BatchRowStatus.Failed;
|
||||||
|
row.ErrorMessage = error;
|
||||||
|
failed++;
|
||||||
|
Logger.LogWarning("Batch edit: upload for new track '{TrackName}' failed: {Error}",
|
||||||
|
row.TrackName, error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Link a cover only when one is actively set ("" clear doesn't apply to
|
||||||
|
// a brand-new track that has no cover yet).
|
||||||
|
if (imagePathForUpdate is { Length: > 0 } linkPath)
|
||||||
|
{
|
||||||
|
var linkResult = await CmsTrackService.UpdateAsync(
|
||||||
|
uploadResult.Value.Id,
|
||||||
|
row.TrackName,
|
||||||
|
_artist,
|
||||||
|
album,
|
||||||
|
genre,
|
||||||
|
releaseDate,
|
||||||
|
linkPath,
|
||||||
|
_releaseType,
|
||||||
|
trackNumber);
|
||||||
|
|
||||||
|
if (!linkResult.Success)
|
||||||
|
{
|
||||||
|
// Non-blocking: track persisted; cover can be linked via TrackEdit.
|
||||||
|
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||||
|
row.TrackName, uploadResult.Value.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Status = BatchRowStatus.Done;
|
||||||
|
succeeded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Batch edit: exception processing '{TrackName}'", row.TrackName);
|
||||||
|
row.Status = BatchRowStatus.Failed;
|
||||||
|
row.ErrorMessage = "Save failed — please try again.";
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_processedCount++;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||||
|
Navigation.NavigateTo("/tracks/albums");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Saved {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||||
|
// Stay on page so the admin can see the failed rows.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_saving = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
|
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single track row shared by <c>BatchUpload</c> (all rows are new uploads) and
|
||||||
|
/// <c>BatchEdit</c> (existing rows carry <see cref="Id"/>; admins may also add new upload rows).
|
||||||
|
/// </summary>
|
||||||
|
public class BatchRowModel
|
||||||
|
{
|
||||||
|
/// <summary>SQL id of an existing track. <c>null</c> means a new row to upload.</summary>
|
||||||
|
public long? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Vault entry key — existing rows only.</summary>
|
||||||
|
public string? EntryKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Original upload filename — existing rows only, read-only display.</summary>
|
||||||
|
public string? OriginalFileName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Selected WAV — new rows only.</summary>
|
||||||
|
public IBrowserFile? WavFile { get; set; }
|
||||||
|
|
||||||
|
public string TrackName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int TrackNumber { get; set; }
|
||||||
|
|
||||||
|
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@if (SelectedTrack is null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="4">
|
||||||
|
<MudTextField Value="SelectedTrack.TrackName"
|
||||||
|
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
|
||||||
|
T="string"
|
||||||
|
Label="Track Name"
|
||||||
|
Required="true"
|
||||||
|
RequiredError="Track Name is required"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Disabled="Disabled" />
|
||||||
|
|
||||||
|
@if (SelectedTrack.Id.HasValue)
|
||||||
|
{
|
||||||
|
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
|
||||||
|
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
|
||||||
|
</MudField>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
|
||||||
|
@if (SelectedTrack.WavFile is { } wav)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
|
||||||
|
}
|
||||||
|
</MudField>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (SelectedTrack.Status == BatchRowStatus.Failed)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error">@SelectedTrack.ErrorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public BatchRowModel? SelectedTrack { get; set; }
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
[Parameter] public EventCallback<string> TrackNameChanged { get; set; }
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
|
||||||
|
|
||||||
|
@if (AllowNewTracks)
|
||||||
|
{
|
||||||
|
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@Disabled" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Tracks.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudList T="BatchRowModel" Class="mt-3">
|
||||||
|
@for (var i = 0; i < Tracks.Count; i++)
|
||||||
|
{
|
||||||
|
var index = i;
|
||||||
|
var row = Tracks[index];
|
||||||
|
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
|
||||||
|
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
|
||||||
|
@StatusChip(row)
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||||
|
Size="Size.Small"
|
||||||
|
Disabled="@(index == 0 || Disabled)"
|
||||||
|
OnClick="@(() => OnMoveUp.InvokeAsync(index))"
|
||||||
|
aria-label="Move track up" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||||
|
Size="Size.Small"
|
||||||
|
Disabled="@(index == Tracks.Count - 1 || Disabled)"
|
||||||
|
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
|
||||||
|
aria-label="Move track down" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||||
|
Color="Color.Error"
|
||||||
|
Size="Size.Small"
|
||||||
|
Disabled="@Disabled"
|
||||||
|
OnClick="@(() => OnRemove.InvokeAsync(index))"
|
||||||
|
aria-label="Remove track" />
|
||||||
|
</MudStack>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</MudList>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public List<BatchRowModel> Tracks { get; set; } = new();
|
||||||
|
[Parameter] public int SelectedIndex { get; set; }
|
||||||
|
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
[Parameter] public bool AllowNewTracks { get; set; } = true;
|
||||||
|
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
|
||||||
|
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
|
||||||
|
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
|
||||||
|
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||||
|
|
||||||
|
private const int MaxFilesPerPick = 50;
|
||||||
|
|
||||||
|
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
|
||||||
|
|
||||||
|
private Task HandleWavFilesSelected(InputFileChangeEventArgs e) =>
|
||||||
|
OnWavFilesSelected.InvokeAsync(e.GetMultipleFiles(MaxFilesPerPick));
|
||||||
|
|
||||||
|
private string RowStyle(int index)
|
||||||
|
{
|
||||||
|
const string baseStyle = "cursor: pointer; border-radius: 4px;";
|
||||||
|
return index == SelectedIndex
|
||||||
|
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
|
||||||
|
: baseStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderFragment StatusChip(BatchRowModel row) => row.Status switch
|
||||||
|
{
|
||||||
|
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||||
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
||||||
|
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
||||||
|
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
||||||
|
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@using DeepDrftManager.Services
|
@using DeepDrftManager.Services
|
||||||
@using DeepDrftModels.Enums
|
@using DeepDrftModels.Enums
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
@inject ICmsTrackService CmsTrackService
|
@inject ICmsTrackService CmsTrackService
|
||||||
@@ -15,140 +16,30 @@
|
|||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
||||||
|
|
||||||
<MudPaper Class="pa-6 mb-4" Elevation="2">
|
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||||
<MudGrid>
|
@bind-Artist="_artist"
|
||||||
<MudItem xs="12" sm="6">
|
@bind-Genre="_genre"
|
||||||
<MudTextField @bind-Value="_albumName" Label="Album Name" Required="true" RequiredError="Album Name is required" Variant="Variant.Outlined" />
|
@bind-ReleaseDate="_releaseDate"
|
||||||
</MudItem>
|
@bind-ReleaseType="_releaseType"
|
||||||
<MudItem xs="12" sm="6">
|
@bind-SelectedImageFile="_selectedImageFile"
|
||||||
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
Disabled="_uploading" />
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" sm="6">
|
|
||||||
<MudSelect T="ReleaseType" @bind-Value="_releaseType" Label="Release Type" Variant="Variant.Outlined">
|
|
||||||
@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">
|
|
||||||
@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="_uploading"
|
|
||||||
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="@_uploading" />
|
|
||||||
@if (_selectedImageFile is not null)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
</MudField>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
</MudPaper>
|
|
||||||
|
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="5">
|
<MudItem xs="12" md="5">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<BatchTrackList Tracks="_tracks"
|
||||||
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
|
@bind-SelectedIndex="_selectedIndex"
|
||||||
|
Disabled="_uploading"
|
||||||
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@_uploading" />
|
OnWavFilesSelected="HandleWavFilesSelected"
|
||||||
|
OnMoveUp="MoveUp"
|
||||||
@if (_tracks.Count == 0)
|
OnMoveDown="MoveDown"
|
||||||
{
|
OnRemove="RemoveRow" />
|
||||||
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudList T="BatchTrackRow" Class="mt-3">
|
|
||||||
@for (var i = 0; i < _tracks.Count; i++)
|
|
||||||
{
|
|
||||||
var index = i;
|
|
||||||
var row = _tracks[index];
|
|
||||||
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
|
|
||||||
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
|
|
||||||
@StatusChip(row)
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="@(index == 0 || _uploading)"
|
|
||||||
OnClick="@(() => MoveUp(index))"
|
|
||||||
aria-label="Move track up" />
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="@(index == _tracks.Count - 1 || _uploading)"
|
|
||||||
OnClick="@(() => MoveDown(index))"
|
|
||||||
aria-label="Move track down" />
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
|
||||||
Color="Color.Error"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="@_uploading"
|
|
||||||
OnClick="@(() => RemoveRow(index))"
|
|
||||||
aria-label="Remove track" />
|
|
||||||
</MudStack>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</MudList>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
<MudItem xs="12" md="7">
|
<MudItem xs="12" md="7">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
@if (_selectedIndex < 0 || _tracks.Count == 0)
|
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||||
{
|
Disabled="_uploading"
|
||||||
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
|
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var selected = _tracks[_selectedIndex];
|
|
||||||
<MudStack Spacing="4">
|
|
||||||
<MudTextField @bind-Value="selected.TrackName"
|
|
||||||
Label="Track Name"
|
|
||||||
Required="true"
|
|
||||||
RequiredError="Track Name is required"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
Disabled="_uploading" />
|
|
||||||
|
|
||||||
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
|
|
||||||
@if (selected.WavFile is { } wav)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
|
|
||||||
}
|
|
||||||
</MudField>
|
|
||||||
|
|
||||||
@if (selected.Status == TrackUploadStatus.Failed)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Error">@selected.ErrorMessage</MudAlert>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
@@ -186,9 +77,7 @@
|
|||||||
// streaming path means the limit caps the request, not in-memory buffering.
|
// streaming path means the limit caps the request, not in-memory buffering.
|
||||||
private const long MaxUploadBytes = 1_073_741_824L;
|
private const long MaxUploadBytes = 1_073_741_824L;
|
||||||
|
|
||||||
private const int MaxFilesPerPick = 50;
|
private List<BatchRowModel> _tracks = new();
|
||||||
|
|
||||||
private List<BatchTrackRow> _tracks = new();
|
|
||||||
private int _selectedIndex = -1;
|
private int _selectedIndex = -1;
|
||||||
private bool _uploading;
|
private bool _uploading;
|
||||||
private int _uploadedCount;
|
private int _uploadedCount;
|
||||||
@@ -203,20 +92,10 @@
|
|||||||
private string _releaseDate = string.Empty;
|
private string _releaseDate = string.Empty;
|
||||||
private ReleaseType _releaseType = ReleaseType.Single;
|
private ReleaseType _releaseType = ReleaseType.Single;
|
||||||
|
|
||||||
private class BatchTrackRow
|
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||||
{
|
|
||||||
public IBrowserFile? WavFile { get; set; }
|
|
||||||
public string TrackName { get; set; } = string.Empty;
|
|
||||||
public TrackUploadStatus Status { get; set; } = TrackUploadStatus.Queued;
|
|
||||||
public string? ErrorMessage { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum TrackUploadStatus { Queued, Uploading, Done, Failed }
|
|
||||||
|
|
||||||
private void HandleWavFilesSelected(InputFileChangeEventArgs e)
|
|
||||||
{
|
{
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
foreach (var file in e.GetMultipleFiles(MaxFilesPerPick))
|
foreach (var file in files)
|
||||||
{
|
{
|
||||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -224,7 +103,7 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_tracks.Add(new BatchTrackRow
|
_tracks.Add(new BatchRowModel
|
||||||
{
|
{
|
||||||
WavFile = file,
|
WavFile = file,
|
||||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||||
@@ -237,8 +116,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectRow(int index) => _selectedIndex = index;
|
|
||||||
|
|
||||||
private void MoveUp(int i)
|
private void MoveUp(int i)
|
||||||
{
|
{
|
||||||
if (i == 0) return;
|
if (i == 0) return;
|
||||||
@@ -262,35 +139,6 @@
|
|||||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string RowStyle(int index)
|
|
||||||
{
|
|
||||||
var baseStyle = "cursor: pointer; border-radius: 4px;";
|
|
||||||
return index == _selectedIndex
|
|
||||||
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
|
|
||||||
: baseStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment StatusChip(BatchTrackRow row) => row.Status switch
|
|
||||||
{
|
|
||||||
TrackUploadStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
|
||||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
|
||||||
TrackUploadStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
|
||||||
TrackUploadStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
|
||||||
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
|
||||||
};
|
|
||||||
|
|
||||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
|
||||||
{
|
|
||||||
_selectedImageFile = e.File;
|
|
||||||
_imagePath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearImage()
|
|
||||||
{
|
|
||||||
_selectedImageFile = null;
|
|
||||||
_imagePath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
private async Task SubmitAsync()
|
||||||
{
|
{
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
@@ -340,6 +188,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||||
_uploading = true;
|
_uploading = true;
|
||||||
_uploadedCount = 0;
|
_uploadedCount = 0;
|
||||||
|
|
||||||
@@ -366,7 +215,7 @@
|
|||||||
var row = _tracks[i];
|
var row = _tracks[i];
|
||||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||||
|
|
||||||
row.Status = TrackUploadStatus.Uploading;
|
row.Status = BatchRowStatus.Uploading;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -392,7 +241,7 @@
|
|||||||
if (!result.Success || result.Value is null)
|
if (!result.Success || result.Value is null)
|
||||||
{
|
{
|
||||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
row.Status = TrackUploadStatus.Failed;
|
row.Status = BatchRowStatus.Failed;
|
||||||
row.ErrorMessage = error;
|
row.ErrorMessage = error;
|
||||||
failed++;
|
failed++;
|
||||||
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
|
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
|
||||||
@@ -422,14 +271,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Status = TrackUploadStatus.Done;
|
row.Status = BatchRowStatus.Done;
|
||||||
succeeded++;
|
succeeded++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
|
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
|
||||||
row.Status = TrackUploadStatus.Failed;
|
row.Status = BatchRowStatus.Failed;
|
||||||
row.ErrorMessage = "Upload failed — please try again.";
|
row.ErrorMessage = "Upload failed — please try again.";
|
||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
@@ -455,15 +304,4 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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