Merge branch 'develop' into feature/cascade-visibility-skip-deleted

This commit is contained in:
2026-08-17 21:17:31 +02:00
18 changed files with 68 additions and 264 deletions
@@ -1,37 +0,0 @@
@* Generic collapsible section with clickable header and chevron toggle *@
<div class="@Class">
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="Toggle" role="button">
@if (TitleContent is not null) {
@TitleContent
} else if (!string.IsNullOrEmpty(Title)) {
<span>@Title</span>
}
<span class="ms-auto small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
</div>
@if (Expanded) {
<div class="@ContentClass">
@ChildContent
</div>
}
</div>
@code {
[Parameter] public string? Title { get; set; }
[Parameter] public RenderFragment? TitleContent { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public bool InitiallyExpanded { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter] public string? ContentClass { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
bool Expanded { get; set; }
protected override void OnInitialized() {
Expanded = InitiallyExpanded;
}
async Task Toggle() {
Expanded = !Expanded;
await ExpandedChanged.InvokeAsync(Expanded);
}
}
@@ -113,8 +113,6 @@
[Parameter] [Parameter]
public FolderFullDto Folder { get; set; } = new FolderFullDto(); public FolderFullDto Folder { get; set; } = new FolderFullDto();
[Parameter]
public EventCallback<Guid> OnDeleted { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> OnDeleteRequested { get; set; } public EventCallback<Guid> OnDeleteRequested { get; set; }
@@ -1,19 +0,0 @@
@* Section wrapper for a group of jobs with title and count *@
<div class="mb-3">
<div class="d-flex align-items-center mb-1">
<h6 class="mb-0 text-muted text-uppercase small">@Title @(Count is not null ? $"({Count})" : "")</h6>
@if (Extra is not null) {
<span class="ms-auto">@Extra</span>
}
</div>
<div class="border rounded">
@ChildContent
</div>
</div>
@code {
[Parameter] public required string Title { get; set; }
[Parameter] public int? Count { get; set; }
[Parameter] public RenderFragment? Extra { get; set; }
[Parameter] public required RenderFragment ChildContent { get; set; }
}
@@ -2,14 +2,13 @@
@using Butter.Dtos.Album @using Butter.Dtos.Album
@using Butter.Dtos.Asset @using Butter.Dtos.Asset
@using Butter.Types @using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable @implements IAsyncDisposable
@inject AlbumService albumService @inject AlbumService albumService
@inject AssetService assetService @inject AssetService assetService
@inject LoginService loginService @inject LoginService loginService
@inject IJSRuntime jsRuntime @inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions @inject MediaService mediaService
@inject NavigationManager navigationManager @inject NavigationManager navigationManager
<PageTitle>@(album?.Name ?? "Album")</PageTitle> <PageTitle>@(album?.Name ?? "Album")</PageTitle>
@@ -157,7 +156,7 @@
style="aspect-ratio:@GetAspectRatio(preview)" style="aspect-ratio:@GetAspectRatio(preview)"
@onclick="() => HandleTileClick(index, assetId)"> @onclick="() => HandleTileClick(index, assetId)">
@if (preview == null || preview.HasThumbnail) { @if (preview == null || preview.HasThumbnail) {
<img src="@ThumbUrl(assetId)" <img src="@mediaService.ThumbUrl(assetId)"
loading="lazy" alt="" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"> <div class="tile-fallback" style="display:none">
@@ -249,10 +248,6 @@
HashSet<Guid> selectedImageIds = []; HashSet<Guid> selectedImageIds = [];
Guid? selectedAssetId; Guid? selectedAssetId;
int selectedIndex = -1; int selectedIndex = -1;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string viewMode = "masonry"; string viewMode = "masonry";
bool _masonrySetup; bool _masonrySetup;
@@ -293,7 +288,6 @@
protected override async Task OnParametersSetAsync() { protected override async Task OnParametersSetAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll"); await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadAlbum(); await LoadAlbum();
} }
@@ -309,9 +303,6 @@
protected override void OnInitialized() { protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged(); loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
} }
async Task LoadAlbum() { async Task LoadAlbum() {
@@ -504,8 +495,6 @@
return "4 / 3"; return "4 / 3";
} }
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry"); await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
@@ -3,13 +3,12 @@
@using Butter.Dtos.Asset @using Butter.Dtos.Asset
@using Butter.Dtos.Person @using Butter.Dtos.Person
@using Butter.Types @using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization @using System.Globalization
@inject PersonService personService @inject PersonService personService
@inject AlbumService albumService @inject AlbumService albumService
@inject AssetService assetService @inject AssetService assetService
@inject LoginService loginService @inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions @inject MediaService mediaService
@inject NavigationManager navigationManager @inject NavigationManager navigationManager
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle> <PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
@@ -40,7 +39,7 @@
var cy = person.ProfileCropY ?? 50; var cy = person.ProfileCropY ?? 50;
var z = person.ProfileCropZoom ?? 0.6f; var z = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-avatar-clip"> <div class="cosplayer-avatar-clip">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt="" <img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-avatar-img" class="cosplayer-avatar-img"
style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" /> style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" />
</div> </div>
@@ -317,8 +316,6 @@
Func<Task>? _pendingDeleteAction; Func<Task>? _pendingDeleteAction;
bool cascadeToAssets; bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public; EVisibility selectedAlbumVisibility = EVisibility.Public;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string viewMode = "masonry"; string viewMode = "masonry";
bool showAlbumAssigner; bool showAlbumAssigner;
List<AlbumPreviewDto>? unassignedAlbums; List<AlbumPreviewDto>? unassignedAlbums;
@@ -341,8 +338,6 @@
int albumLoadVersion; int albumLoadVersion;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
List<Guid>? allAssetIds => person?.Albums? List<Guid>? allAssetIds => person?.Albums?
.SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1)) .SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1))
.Where(id => id != Guid.Empty) .Where(id => id != Guid.Empty)
@@ -350,15 +345,11 @@
.ToList(); .ToList();
protected override async Task OnParametersSetAsync() { protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadPerson(); await LoadPerson();
} }
protected override void OnInitialized() { protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged(); loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
} }
async Task LoadPerson() { async Task LoadPerson() {
@@ -372,7 +363,7 @@
.Where(a => a.CoverAssetId.HasValue) .Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next()) .OrderBy(_ => Random.Shared.Next())
.Take(3) .Take(3)
.Select(a => ThumbUrl(a.CoverAssetId!.Value)) .Select(a => mediaService.ThumbUrl(a.CoverAssetId!.Value))
.ToList(); .ToList();
} }
@@ -522,6 +513,4 @@
if (_pendingDeleteAction != null) if (_pendingDeleteAction != null)
await _pendingDeleteAction(); await _pendingDeleteAction();
} }
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
} }
+3 -14
View File
@@ -1,13 +1,12 @@
@page "/" @page "/"
@using Butter.Dtos.Asset @using Butter.Dtos.Asset
@using Butter.Types @using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable @implements IAsyncDisposable
@inject AssetService assetService @inject AssetService assetService
@inject LoginService loginService @inject LoginService loginService
@inject IJSRuntime jsRuntime @inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions @inject MediaService mediaService
<PageTitle>Home</PageTitle> <PageTitle>Home</PageTitle>
@@ -36,7 +35,7 @@
style="aspect-ratio:@GetAspectRatio(asset)" style="aspect-ratio:@GetAspectRatio(asset)"
@onclick="() => OpenPreview(asset, capturedIndex)"> @onclick="() => OpenPreview(asset, capturedIndex)">
@if (asset.HasThumbnail) { @if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" <img src="@mediaService.ThumbUrl(asset.Id)"
loading="lazy" alt="" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"> <div class="tile-fallback" style="display:none">
@@ -136,18 +135,10 @@
ElementReference topSentinelRef; ElementReference topSentinelRef;
DotNetObjectReference<Home>? dotNetRef; DotNetObjectReference<Home>? dotNetRef;
Guid _randomSeed; Guid _randomSeed;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged(); loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => { loginService.AuthInfoChanged += (_, _) => StateHasChanged();
token = info?.Token ?? string.Empty;
StateHasChanged();
};
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadFirstPage(); await LoadFirstPage();
} }
@@ -277,8 +268,6 @@
return "4 / 3"; return "4 / 3";
} }
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll"); await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
if (dotNetRef != null) { if (dotNetRef != null) {
@@ -10,16 +10,13 @@
</div> </div>
} else { } else {
<div class="progress" role="progressbar" style="height: @Height"> <div class="progress" role="progressbar" style="height: @Height">
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%"> <div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%"></div>
@if (ShowPercent) { @($"{Value * 100:F0}%") }
</div>
</div> </div>
} }
@code { @code {
[Parameter] public float Value { get; set; } [Parameter] public float Value { get; set; }
[Parameter] public string? Color { get; set; } [Parameter] public string? Color { get; set; }
[Parameter] public bool ShowPercent { get; set; }
[Parameter] public string Size { get; set; } = "sm"; [Parameter] public string Size { get; set; } = "sm";
[Parameter] public IReadOnlyList<(float Width, string Color)>? Segments { get; set; } [Parameter] public IReadOnlyList<(float Width, string Color)>? Segments { get; set; }
@@ -124,13 +124,6 @@ public static class RegexHighlighter
return new MarkupString(sb.ToString()); return new MarkupString(sb.ToString());
} }
/// <summary>
/// Returns the set of CSS class names used by the highlighter for embedding in isolated CSS.
/// </summary>
public static string CssClasses => string.Join(" ",
ClassAnchor, ClassQuantifier, ClassCharClass, ClassEscape,
ClassNamedGroup, ClassGroupRef, ClassOperator, ClassLiteral);
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start) => private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start) =>
FindMatchingParen(span, start); FindMatchingParen(span, start);
@@ -2,14 +2,13 @@
@using Butter.Dtos.Asset @using Butter.Dtos.Asset
@using Butter.Dtos.Person @using Butter.Dtos.Person
@using Butter.Types @using Butter.Types
@using Microsoft.Extensions.Options
@implements IDisposable @implements IDisposable
@inject AssetService assetService @inject AssetService assetService
@inject AlbumService albumService @inject AlbumService albumService
@inject PersonService personService @inject PersonService personService
@inject LoginService loginService @inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions @inject MediaService mediaService
@if (Embedded) { @if (Embedded) {
<div class="picker-embedded"> <div class="picker-embedded">
@@ -86,7 +85,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")" <div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)"> @key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) { @if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt="" <img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div> <div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else { } else {
@@ -137,7 +136,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")" <div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)"> @key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) { @if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt="" <img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div> <div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else { } else {
@@ -254,7 +253,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")" <div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)"> @key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) { @if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt="" <img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div> <div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else { } else {
@@ -305,7 +304,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")" <div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)"> @key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) { @if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt="" <img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div> <div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else { } else {
@@ -429,7 +428,6 @@
string currentPath = ""; string currentPath = "";
HashSet<Guid> selectedIds = []; HashSet<Guid> selectedIds = [];
string searchQuery = string.Empty; string searchQuery = string.Empty;
string token = string.Empty;
int currentPage; int currentPage;
bool isLoading; bool isLoading;
bool hasMore = true; bool hasMore = true;
@@ -455,15 +453,12 @@
const int PageSize = 150; const int PageSize = 150;
bool isAdminOrCurator => loginService.IsAdminOrCurator; bool isAdminOrCurator => loginService.IsAdminOrCurator;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
if (Embedded) await InitializeAsync(); if (Embedded) await InitializeAsync();
} }
protected override async Task OnParametersSetAsync() { protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
if (!Embedded && Show && !_initializing) { if (!Embedded && Show && !_initializing) {
_initializing = true; _initializing = true;
await InitializeAsync(); await InitializeAsync();
@@ -613,8 +608,6 @@
_personSearchTimer?.Dispose(); _personSearchTimer?.Dispose();
} }
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
static string TruncateName(string name, int maxLen = 40) { static string TruncateName(string name, int maxLen = 40) {
if (name.Length <= maxLen) return name; if (name.Length <= maxLen) return name;
var lastSlash = name.LastIndexOf('/'); var lastSlash = name.LastIndexOf('/');
@@ -1,15 +1,14 @@
@using Butter.Dtos.Album @using Butter.Dtos.Album
@using Butter.Types @using Butter.Types
@using Microsoft.Extensions.Options
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService @inject LoginService loginService
@inject MediaService mediaService
<div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()"> <div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()">
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name" <a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a> @onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
@if (Album.CoverAssetId.HasValue) { @if (Album.CoverAssetId.HasValue) {
<img src="@ThumbUrl(Album.CoverAssetId.Value)" <img src="@mediaService.ThumbUrl(Album.CoverAssetId.Value)"
loading="lazy" alt="" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="album-card-fallback" style="display:none"> <div class="album-card-fallback" style="display:none">
@@ -54,12 +53,6 @@
[Parameter] [Parameter]
public string ViewMode { get; set; } = "masonry"; public string ViewMode { get; set; } = "masonry";
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string token => loginService.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
string GetCardClass() { string GetCardClass() {
var classes = new List<string> { "album-card" }; var classes = new List<string> { "album-card" };
if (SelectionMode) { if (SelectionMode) {
@@ -1,9 +1,6 @@
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@using System.Linq @using System.Linq
@inject LoginService loginService @inject MediaService mediaService
@inject IOptions<ServiceOptions> serviceOptions
<ModalFrame Title="Select Cover Image" Show="Show" Size="lg" OnClose="OnCancel"> <ModalFrame Title="Select Cover Image" Show="Show" Size="lg" OnClose="OnCancel">
<Body> <Body>
@@ -15,7 +12,7 @@
<div class="picker-tile @(SelectedAssetId == id ? "selected" : "")" <div class="picker-tile @(SelectedAssetId == id ? "selected" : "")"
@key="id" @key="id"
@onclick="() => SelectAsset(id)"> @onclick="() => SelectAsset(id)">
<img src="@ThumbUrl(id)" <img src="@mediaService.ThumbUrl(id)"
loading="lazy" alt="" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"> <div class="tile-fallback" style="display:none">
@@ -47,15 +44,6 @@
[Parameter] public EventCallback<Guid?> OnSelected { get; set; } [Parameter] public EventCallback<Guid?> OnSelected { get; set; }
[Parameter] public EventCallback OnClosed { get; set; } [Parameter] public EventCallback OnClosed { get; set; }
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override void OnParametersSet() {
token = loginService.AuthInfo?.Token ?? string.Empty;
}
void SelectAsset(Guid id) { void SelectAsset(Guid id) {
SelectedAssetId = id; SelectedAssetId = id;
} }
@@ -68,6 +56,4 @@
async Task OnCancel() { async Task OnCancel() {
await OnClosed.InvokeAsync(); await OnClosed.InvokeAsync();
} }
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
} }
@@ -1,12 +1,11 @@
@using Butter.Dtos.Person @using Butter.Dtos.Person
@using Butter.Types @using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization @using System.Globalization
@implements IAsyncDisposable @implements IAsyncDisposable
@inject PersonService personService @inject PersonService personService
@inject LoginService loginService @inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions @inject MediaService mediaService
@inject IJSRuntime jsRuntime @inject IJSRuntime jsRuntime
@if (isLoading) { @if (isLoading) {
@@ -34,7 +33,7 @@
var cy1 = person.ProfileCropY ?? 50; var cy1 = person.ProfileCropY ?? 50;
var zoom1 = person.ProfileCropZoom ?? 0.6f; var zoom1 = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-card-img-wrap"> <div class="cosplayer-card-img-wrap">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt="" <img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-card-img" class="cosplayer-card-img"
style="width: calc(100% / @zoom1.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx1.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy1.ToString(CultureInfo.InvariantCulture) * 1%))" style="width: calc(100% / @zoom1.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx1.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy1.ToString(CultureInfo.InvariantCulture) * 1%))"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" /> onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
@@ -92,14 +91,6 @@
string? _prevSearch; string? _prevSearch;
string? _prevSortBy; string? _prevSortBy;
bool _prevSortAsc; bool _prevSortAsc;
string _token = string.Empty;
string _apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(_token) ? "" : $"?token={_token}";
protected override void OnInitialized() {
loginService.AuthInfoChanged += OnAuthInfoChanged;
_token = loginService.AuthInfo?.Token ?? string.Empty;
}
protected override async Task OnParametersSetAsync() { protected override async Task OnParametersSetAsync() {
bool filterChanged = _prevLoadVersion != LoadVersion bool filterChanged = _prevLoadVersion != LoadVersion
@@ -183,8 +174,6 @@
} }
} }
string ThumbUrl(Guid id) => $"{_apiBase}/api/media/thumb/{id}{TokenParam}";
string GetCardClass(PersonPreviewDto person) { string GetCardClass(PersonPreviewDto person) {
var classes = new List<string> { "cosplayer-card" }; var classes = new List<string> { "cosplayer-card" };
if (SelectionMode) { if (SelectionMode) {
@@ -198,10 +187,6 @@
return string.Join(" ", classes); return string.Join(" ", classes);
} }
void OnAuthInfoChanged(object? _, AuthInfo? info) {
_token = info?.Token ?? string.Empty;
}
async Task DisposeObserver() { async Task DisposeObserver() {
if (dotNetRef != null) { if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose"); await jsRuntime.InvokeVoidAsync("cosplayerObserver.dispose");
@@ -211,7 +196,6 @@
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
loginService.AuthInfoChanged -= OnAuthInfoChanged;
await DisposeObserver(); await DisposeObserver();
} }
@@ -1,8 +1,5 @@
@using Microsoft.Extensions.Options
@inject IOptions<ServiceOptions> Options
@inject LoginService LoginService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject MediaService mediaService
@if (Show && AssetId != Guid.Empty) { @if (Show && AssetId != Guid.Empty) {
<div class="preview-overlay" @onkeydown="OnKeyDown" tabindex="0" @onclick="OnCloseCallback"> <div class="preview-overlay" @onkeydown="OnKeyDown" tabindex="0" @onclick="OnCloseCallback">
@@ -18,10 +15,10 @@
<div class="preview-image-wrapper"> <div class="preview-image-wrapper">
@if (HasPreview) { @if (HasPreview) {
<img class="preview-img" src="@PreviewUrl" alt="" <img class="preview-img" src="@mediaService.PreviewUrl(AssetId)" alt=""
onerror="this.onerror=null;this.src='@ThumbUrl'" /> onerror="this.onerror=null;this.src='@mediaService.ThumbUrl(AssetId)'" />
} else { } else {
<img class="preview-img" src="@ThumbUrl" alt="" /> <img class="preview-img" src="@mediaService.ThumbUrl(AssetId)" alt="" />
} }
<div class="preview-info"> <div class="preview-info">
<div class="preview-info-text"> <div class="preview-info-text">
@@ -32,7 +29,7 @@
<span class="preview-info-filename">@Filename</span> <span class="preview-info-filename">@Filename</span>
} }
<div class="preview-info-actions"> <div class="preview-info-actions">
<a class="preview-action-btn" href="@OriginalUrl" target="_blank" <a class="preview-action-btn" href="@mediaService.OriginalUrl(AssetId)" target="_blank"
title="View full size" @onclick:stopPropagation="true"> title="View full size" @onclick:stopPropagation="true">
<i class="bi bi-arrows-fullscreen"></i> <i class="bi bi-arrows-fullscreen"></i>
</a> </a>
@@ -59,18 +56,6 @@
[Parameter] public EventCallback OnClose { get; set; } [Parameter] public EventCallback OnClose { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; }
string token = string.Empty;
string apiBase => Options.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ThumbUrl => $"{apiBase}/api/media/thumb/{AssetId}{TokenParam}";
string PreviewUrl => $"{apiBase}/api/media/preview/{AssetId}{TokenParam}";
string OriginalUrl => $"{apiBase}/api/media/original/{AssetId}{TokenParam}";
protected override void OnInitialized() {
LoginService.AuthInfoChanged += (_, info) => { token = info?.Token ?? string.Empty; };
token = LoginService.AuthInfo?.Token ?? string.Empty;
}
async Task OnCloseCallback() { async Task OnCloseCallback() {
await OnClose.InvokeAsync(); await OnClose.InvokeAsync();
} }
@@ -90,6 +75,6 @@
} }
async Task Download() { async Task Download() {
await JSRuntime.InvokeVoidAsync("masonryObserver.downloadFile", OriginalUrl); await JSRuntime.InvokeVoidAsync("masonryObserver.downloadFile", mediaService.OriginalUrl(AssetId));
} }
} }
@@ -1,7 +1,5 @@
@using Microsoft.Extensions.Options
@inject IJSRuntime jsRuntime @inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions @inject MediaService mediaService
@inject LoginService loginService
@if (Show && AssetId.HasValue) { @if (Show && AssetId.HasValue) {
<div class="modal-overlay" style="background: rgba(0, 0, 0, 0.85)" @onclick="OnCancel"> <div class="modal-overlay" style="background: rgba(0, 0, 0, 0.85)" @onclick="OnCancel">
@@ -72,11 +70,6 @@
/// </summary> /// </summary>
[Parameter] public EventCallback OnClosed { get; set; } [Parameter] public EventCallback OnClosed { get; set; }
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string token => loginService.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ImageUrl => $"{apiBase}/api/media/thumb/{AssetId!.Value}{TokenParam}";
protected override void OnInitialized() { protected override void OnInitialized() {
_instanceId = ++_counter; _instanceId = ++_counter;
} }
@@ -93,7 +86,7 @@
if (_pendingInit) { if (_pendingInit) {
_pendingInit = false; _pendingInit = false;
await jsRuntime.InvokeVoidAsync("profileCropper.initialize", await jsRuntime.InvokeVoidAsync("profileCropper.initialize",
ContainerId, ImageUrl, CropX ?? 50, CropY ?? 50, CropZoom ?? 0.6f); ContainerId, mediaService.ThumbUrl(AssetId!.Value), CropX ?? 50, CropY ?? 50, CropZoom ?? 0.6f);
} }
} }
@@ -1,6 +1,5 @@
@using Butter.Dtos.Album @using Butter.Dtos.Album
@using Butter.Dtos.Person @using Butter.Dtos.Person
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services @using MilkStream.Client.Services
@namespace MilkStream.Client.Components.Shared @namespace MilkStream.Client.Components.Shared
@@ -8,8 +7,7 @@
@inject PersonService PersonSvc @inject PersonService PersonSvc
@inject AlbumService AlbumSvc @inject AlbumService AlbumSvc
@inject NavigationManager Nav @inject NavigationManager Nav
@inject IOptions<ServiceOptions> ServiceOptions @inject MediaService mediaService
@inject LoginService LoginSvc
<div class="search-dropdown-container position-relative"> <div class="search-dropdown-container position-relative">
<div class="d-flex"> <div class="d-flex">
@@ -67,7 +65,7 @@
@onmouseenter="() => OnItemHover(idx)"> @onmouseenter="() => OnItemHover(idx)">
@if (item.ImageId.HasValue) @if (item.ImageId.HasValue)
{ {
<img class="search-dropdown-thumb" src="@ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" /> <img class="search-dropdown-thumb" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
} }
else else
{ {
@@ -85,7 +83,7 @@
@onmouseenter="() => OnItemHover(idx)"> @onmouseenter="() => OnItemHover(idx)">
@if (item.ImageId.HasValue) @if (item.ImageId.HasValue)
{ {
<img class="search-dropdown-thumb-rounded" src="@ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" /> <img class="search-dropdown-thumb-rounded" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
} }
else else
{ {
@@ -117,12 +115,6 @@
int _selectedIndex = -1; int _selectedIndex = -1;
CancellationTokenSource? _blurCts; CancellationTokenSource? _blurCts;
string ApiBase => ServiceOptions.Value.BaseUrl.TrimEnd('/');
string Token => LoginSvc.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(Token) ? "" : $"?token={Token}";
string ThumbUrl(Guid id) => $"{ApiBase}/api/media/thumb/{id}{TokenParam}";
async Task OnInput(ChangeEventArgs e) async Task OnInput(ChangeEventArgs e)
{ {
_query = e.Value?.ToString() ?? string.Empty; _query = e.Value?.ToString() ?? string.Empty;
@@ -12,15 +12,6 @@ public sealed class FoldersService(
LoginService loginService, LoginService loginService,
ILogger<FoldersService> logger ILogger<FoldersService> logger
) : AuthServiceBase(options, httpClientFactory, loginService, logger) { ) : AuthServiceBase(options, httpClientFactory, loginService, logger) {
/// <summary>
/// Fired when folders are saved.
/// </summary>
public event EventHandler? SaveFolders;
/// <summary>
/// Invokes the <see cref="SaveFolders"/> event.
/// </summary>
public void OnSaveFolders() => SaveFolders?.Invoke(this, EventArgs.Empty);
/// <summary> /// <summary>
/// Gets all tracked folders. /// Gets all tracked folders.
/// </summary> /// </summary>
@@ -33,19 +24,6 @@ public sealed class FoldersService(
return null; return null;
} }
/// <summary>
/// Gets a folder by its ID.
/// </summary>
/// <param name="id">The folder ID.</param>
/// <returns>The folder details, or null if not found.</returns>
public async Task<FolderFullDto?> GetFolderById(Guid id) {
var response = await Client.GetAsync($"/api/folder/{id}");
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FolderFullDto>();
return null;
}
/// <summary> /// <summary>
/// Creates a new folder. /// Creates a new folder.
/// </summary> /// </summary>
+29 -20
View File
@@ -3,28 +3,37 @@ using Microsoft.Extensions.Options;
namespace MilkStream.Client.Services; namespace MilkStream.Client.Services;
/// <summary> /// <summary>
/// Provides access to media files (original, thumbnails, previews) via the API. /// Builds media URLs (original, thumbnail, preview) authenticated via a token query parameter.
/// Media is served by <c>MediaController</c> through <c>&lt;img&gt;</c>-accessible URLs, so the
/// JWT is passed as a <c>token</c> query parameter rather than an Authorization header.
/// </summary> /// </summary>
public sealed class MediaService : AuthServiceBase { /// <param name="options">Service configuration options.</param>
/// <inheritdoc /> /// <param name="loginService">The login service providing the current auth token.</param>
protected override HttpClient Client { get; init; } public sealed class MediaService(IOptions<ServiceOptions> options, LoginService loginService) {
/// <inheritdoc /> string BaseUrl => options.Value.BaseUrl.TrimEnd('/');
protected override ILogger Logger { get; init; }
string TokenParam {
get {
var token = loginService.AuthInfo?.Token;
return string.IsNullOrEmpty(token) ? "" : $"?token={token}";
}
}
/// <summary> /// <summary>
/// Initialises a new instance of the <see cref="MediaService"/> class. /// Gets the thumbnail URL for an asset.
/// </summary> /// </summary>
/// <param name="options">Service configuration options.</param> /// <param name="id">The asset ID.</param>
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param> public string ThumbUrl(Guid id) => $"{BaseUrl}/api/media/thumb/{id}{TokenParam}";
/// <param name="loginService">The login service for authentication.</param>
/// <param name="logger">Logger instance.</param> /// <summary>
public MediaService( /// Gets the preview URL for an asset.
IOptions<ServiceOptions> options, /// </summary>
IHttpClientFactory httpClientFactory, /// <param name="id">The asset ID.</param>
LoginService loginService, public string PreviewUrl(Guid id) => $"{BaseUrl}/api/media/preview/{id}{TokenParam}";
ILogger<MediaService> logger
) : base(options, httpClientFactory, loginService, logger) { /// <summary>
Client = base.Client; /// Gets the original image URL for an asset.
Logger = logger; /// </summary>
} /// <param name="id">The asset ID.</param>
public string OriginalUrl(Guid id) => $"{BaseUrl}/api/media/original/{id}{TokenParam}";
} }
-8
View File
@@ -1,8 +0,0 @@
namespace MilkStream.Client;
/// <summary>
/// Provides extension methods for string operations.
/// </summary>
public class StringExtensions {
}