Merge cms-w3-t4-edit: PUT api/cms/track/{id} + /cms/tracks/{id} edit page

This commit is contained in:
Daniel Harvey
2026-05-18 16:15:35 -04:00
2 changed files with 294 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
@page "/cms/tracks/{Id:int}"
@* InteractiveServer: page injects ITrackService in-process; ITokenService reads localStorage via JS interop over the circuit. *@
@rendermode InteractiveServer
@using AuthBlocksWeb.HierarchicalAuthorize
@using AuthBlocksWeb.Services
@using DeepDrftWeb.Services
@using System.Net.Http.Headers
@using System.Net.Http.Json
@attribute [HierarchicalRoleAuthorize("Admin")]
@inject ITrackService TrackService
@inject IHttpClientFactory HttpClientFactory
@inject ITokenService TokenService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@inject ILogger<TrackEdit> Logger
<PageTitle>Edit Track — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/cms/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" />
<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 {
[Parameter] public int Id { get; set; }
private TrackEntity? _track;
private TrackEditForm _form = new();
private bool _loading = true;
private bool _busy;
private bool CanSave =>
!string.IsNullOrWhiteSpace(_form.TrackName)
&& !string.IsNullOrWhiteSpace(_form.Artist);
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
var result = await TrackService.GetById(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
{
var http = HttpClientFactory.CreateClient("DeepDrft.API");
await AttachBearerAsync(http);
var payload = new
{
TrackName = _form.TrackName,
Artist = _form.Artist,
Album = string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
Genre = string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
ReleaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null
};
var response = await http.PutAsJsonAsync($"api/cms/track/{Id}", payload);
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Track updated.", Severity.Success);
await LoadAsync();
}
else
{
Snackbar.Add($"Save failed: {(int)response.StatusCode} {response.ReasonPhrase}", 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;
}
}
// DELETE api/cms/track/{Id} is handled by CmsDeleteController (T3 branch).
private async Task ConfirmDelete()
{
if (_track is null) return;
var confirmed = await DialogService.ShowMessageBox(
"Delete track",
$"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.",
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
_busy = true;
try
{
var http = HttpClientFactory.CreateClient("DeepDrft.API");
await AttachBearerAsync(http);
var response = await http.DeleteAsync($"api/cms/track/{Id}");
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Track deleted.", Severity.Success);
Nav.NavigateTo("/cms/tracks");
}
else
{
Snackbar.Add($"Delete failed: {(int)response.StatusCode} {response.ReasonPhrase}", Severity.Error);
_busy = false;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Delete failed for track {TrackId}", Id);
Snackbar.Add("Delete failed — please try again.", Severity.Error);
_busy = false;
}
}
private async Task AttachBearerAsync(HttpClient http)
{
var token = await TokenService.GetAccessTokenAsync();
if (!string.IsNullOrEmpty(token))
{
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
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 DateTime? ReleaseDate { get; set; }
public static TrackEditForm From(TrackEntity track) => new()
{
TrackName = track.TrackName,
Artist = track.Artist,
Album = track.Album,
Genre = track.Genre,
ReleaseDate = track.ReleaseDate is { } d
? d.ToDateTime(TimeOnly.MinValue)
: null
};
}
}
@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
using DeepDrftModels.Entities;
using DeepDrftWeb.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NetBlocks.Models;
namespace DeepDrftWeb.Controllers;
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/cms/track")]
public class CmsEditController : ControllerBase
{
private readonly ITrackService _trackService;
public CmsEditController(ITrackService trackService)
{
_trackService = trackService;
}
// Metadata-only update. EntryKey is immutable in Wave 1 — audio replacement
// is a separate Wave 2 operation that touches the vault.
[HttpPut("{id:int}")]
public async Task<ActionResult<ApiResultDto<TrackEntity>>> Update(int id, [FromBody] CmsTrackUpdateRequest request)
{
var existing = await _trackService.GetById(id);
if (!existing.Success)
{
var failure = ApiResult<TrackEntity>.CreateFailResult(existing.GetMessage());
return StatusCode(500, new ApiResultDto<TrackEntity>(failure));
}
if (existing.Value is null)
{
return NotFound();
}
var track = existing.Value;
track.TrackName = request.TrackName;
track.Artist = request.Artist;
track.Album = request.Album;
track.Genre = request.Genre;
track.ReleaseDate = request.ReleaseDate;
var updated = await _trackService.Update(track);
var apiResult = ApiResult<TrackEntity>.From(updated);
var dto = new ApiResultDto<TrackEntity>(apiResult);
return updated.Success ? Ok(dto) : StatusCode(500, dto);
}
}
public record CmsTrackUpdateRequest(
[Required, MaxLength(200)] string TrackName,
[Required, MaxLength(200)] string Artist,
[MaxLength(200)] string? Album,
[MaxLength(100)] string? Genre,
DateOnly? ReleaseDate);