Merge branch 'p2-w2-t2-cms-image' into dev
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
@page "/tracks/{Id:long}"
|
||||
@using DeepDrftManager.Services
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@@ -60,6 +62,48 @@
|
||||
Label="Genre"
|
||||
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"
|
||||
@@ -94,11 +138,25 @@
|
||||
private TrackEditForm _form = new();
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
|
||||
private bool CanSave =>
|
||||
!string.IsNullOrWhiteSpace(_form.TrackName)
|
||||
&& !string.IsNullOrWhiteSpace(_form.Artist);
|
||||
|
||||
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit
|
||||
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes.
|
||||
private string? ImagePreviewUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(_form.ImagePath)) return null;
|
||||
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
if (baseAddress is null) return null;
|
||||
return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
@@ -123,14 +181,33 @@
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content
|
||||
// API loads the authoritative row and applies these fields.
|
||||
// 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);
|
||||
releaseDate,
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath);
|
||||
if (updated.Success)
|
||||
{
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
@@ -153,6 +230,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -197,6 +285,7 @@
|
||||
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 static TrackEditForm From(TrackDto track) => new()
|
||||
@@ -205,6 +294,7 @@
|
||||
Artist = track.Artist,
|
||||
Album = track.Album,
|
||||
Genre = track.Genre,
|
||||
ImagePath = track.ImagePath,
|
||||
ReleaseDate = track.ReleaseDate is { } d
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null
|
||||
|
||||
@@ -241,9 +241,87 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
|
||||
};
|
||||
|
||||
public async Task<ResultContainer<string>> UploadImageAsync(
|
||||
Stream imageStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType) || !KnownImageMimeTypes.Contains(contentType))
|
||||
{
|
||||
_logger.LogWarning("UploadImageAsync rejected: unsupported or missing content type '{ContentType}'", contentType);
|
||||
return ResultContainer<string>.CreateFailResult($"Unsupported image type: {contentType}. Accepted: JPEG, PNG, GIF, WebP, SVG, BMP.");
|
||||
}
|
||||
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
var imageContent = new StreamContent(imageStream);
|
||||
imageContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
multipart.Add(imageContent, "image", fileName);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "api/image/upload") { Content = multipart };
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for image upload of {FileName}", fileName);
|
||||
return ResultContainer<string>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
if (statusCode >= 500)
|
||||
{
|
||||
_logger.LogError("Content API returned {Status} for image upload of {FileName}: {Body}", statusCode, fileName, body);
|
||||
return ResultContainer<string>.CreateFailResult("Image upload failed on the content server.");
|
||||
}
|
||||
|
||||
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
|
||||
_logger.LogWarning("Content API rejected image upload: {Status} {Body}", statusCode, body);
|
||||
return ResultContainer<string>.CreateFailResult(
|
||||
string.IsNullOrWhiteSpace(body) ? $"Image upload rejected ({statusCode})." : body);
|
||||
}
|
||||
|
||||
ImageUploadResponse? payload;
|
||||
try
|
||||
{
|
||||
payload = await response.Content.ReadFromJsonAsync<ImageUploadResponse>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize image upload response from Content API");
|
||||
return ResultContainer<string>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (payload is null || string.IsNullOrWhiteSpace(payload.EntryKey))
|
||||
{
|
||||
_logger.LogError("Content API returned an empty entry key for image upload");
|
||||
return ResultContainer<string>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<string>.CreatePassResult(payload.EntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ImageUploadResponse(string EntryKey);
|
||||
|
||||
public async Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -254,6 +332,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
album,
|
||||
genre,
|
||||
releaseDate,
|
||||
imagePath,
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
|
||||
@@ -50,13 +50,25 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto?>> GetByIdAsync(long id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upload a cover-art image to the images vault via <c>POST api/image/upload</c>.
|
||||
/// Returns the generated entry key on success. Maps a 400 to a validation failure message.
|
||||
/// </summary>
|
||||
Task<ResultContainer<string>> UploadImageAsync(
|
||||
Stream imageStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
|
||||
/// not part of the update.
|
||||
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
|
||||
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
|
||||
/// </summary>
|
||||
Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user