Merge branch 'p2-w2-t2-cms-image' into dev

This commit is contained in:
daniel-c-harvey
2026-06-07 16:41:41 -04:00
3 changed files with 185 additions and 4 deletions
@@ -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;
+13 -1
View File
@@ -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>