Files
MilkyShots/MilkStream.Client/Components/Pages/Home.razor
REDCODE 29b021bee5 feat: pass JWT token in media URLs for authenticated thumbnail/preview requests
- MediaController: accept ?token=<jwt> query param as alt auth for browser img requests,
  validating against signing key via JwtSecurityTokenHandler
- Home.razor: append current JWT token to all thumbnail/preview URLs,
  update on AuthInfoChanged, empty when not logged in
2026-07-07 15:58:56 +02:00

204 lines
6.9 KiB
Plaintext

@page "/"
@using Butter.Dtos.Asset
@using Butter.Types
@using Microsoft.Extensions.Options
@using Microsoft.JSInterop
@using MilkStream.Client.Components.Layout
@using MilkStream.Client.Services
@implements IAsyncDisposable
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
<PageTitle>Home</PageTitle>
@if (isLoading) {
<LoadSpinner/>
} else if (flatList.Count == 0) {
<div class="border-warning border-5 border-opacity-100 rounded-3 p-3 m-5 bg-warning-subtle text-center">
<h2 class="text-warning">No media available</h2>
@if (loginService.IsLoggedIn) {
<p class="text-warning-emphasis">You are logged in, but there is no media available at the moment.</p>
} else {
<p class="text-warning-emphasis">Please <a href="/login" class="link-warning">log-in</a> to be able to browse any media.</p>
}
</div>
} else {
<div class="masonry-grid">
@{ var idx = 0; }
@foreach (var page in allAssetPages) {
@foreach (var asset in page) {
var i = idx++;
<div class="masonry-tile" @key="asset.Id"
style="aspect-ratio:@GetAspectRatio(asset)"
@onclick="() => OpenPreview(asset, i)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
<i class="bi bi-image"></i>
</div>
} else {
<div class="tile-fallback">
<i class="bi bi-image"></i>
</div>
}
</div>
}
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
}
@if (selectedAsset != null) {
<div class="preview-overlay" @onkeydown="OnPreviewKeyDown" tabindex="0" @onclick="ClosePreview">
<div class="preview-content" @onclick:stopPropagation="true">
<button class="preview-close" @onclick="ClosePreview">&times;</button>
@if (selectedIndex > 0) {
<button class="preview-nav preview-prev" @onclick="() => NavigatePreview(-1)" @onclick:stopPropagation="true">&#8249;</button>
}
@if (selectedIndex < flatList.Count - 1) {
<button class="preview-nav preview-next" @onclick="() => NavigatePreview(1)" @onclick:stopPropagation="true">&#8250;</button>
}
@if (selectedAsset.HasPreview) {
<img class="preview-img" src="@PreviewUrl(selectedAsset.Id)" alt=""
onerror="this.onerror=null;this.src='@ThumbUrl(selectedAsset.Id)'" />
} else {
<img class="preview-img" src="@ThumbUrl(selectedAsset.Id)" alt="" />
}
</div>
</div>
}
@code {
List<List<AssetPreviewDto>> allAssetPages = [];
List<AssetPreviewDto> flatList = [];
int currentPage = 1;
bool hasMore = true;
bool isLoading = true;
bool isLoadingMore;
AssetPreviewDto? selectedAsset;
int selectedIndex = -1;
ElementReference sentinelRef;
DotNetObjectReference<Home>? dotNetRef;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
StateHasChanged();
};
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadFirstPage();
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (flatList.Count > 0 && hasMore && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"masonryObserver.observe", sentinelRef, dotNetRef
);
}
if (selectedAsset != null)
await jsRuntime.InvokeVoidAsync("masonryObserver.lockBodyScroll");
else
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
}
async Task LoadFirstPage() {
isLoading = true;
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, 1, 30);
if (items?.Count > 0) {
allAssetPages.Add(items);
RebuildFlatList();
currentPage = 2;
}
hasMore = items?.Count == 30;
isLoading = false;
}
[JSInvokable]
public async Task LoadNextPage() {
if (isLoadingMore || !hasMore) return;
isLoadingMore = true;
StateHasChanged();
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, currentPage, 30);
if (items?.Count > 0) {
allAssetPages.Add(items);
RebuildFlatList();
currentPage++;
if (allAssetPages.Count > 10)
allAssetPages.RemoveAt(0);
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
void RebuildFlatList() => flatList = allAssetPages.SelectMany(p => p).ToList();
void OpenPreview(AssetPreviewDto asset, int index) {
selectedAsset = asset;
selectedIndex = index;
}
void ClosePreview() {
selectedAsset = null;
selectedIndex = -1;
}
void NavigatePreview(int direction) {
var newIndex = selectedIndex + direction;
if (newIndex < 0 || newIndex >= flatList.Count) return;
selectedAsset = flatList[newIndex];
selectedIndex = newIndex;
}
void OnPreviewKeyDown(KeyboardEventArgs e) {
switch (e.Key) {
case "Escape":
ClosePreview();
break;
case "ArrowLeft":
NavigatePreview(-1);
break;
case "ArrowRight":
NavigatePreview(1);
break;
}
}
static string GetAspectRatio(AssetPreviewDto asset) {
if (asset.ResolutionWidth > 0 && asset.ResolutionHeight > 0)
return $"{asset.ResolutionWidth} / {asset.ResolutionHeight}";
return "4 / 3";
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
string PreviewUrl(Guid id) => $"{apiBase}/api/media/preview/{id}{TokenParam}";
public async ValueTask DisposeAsync() {
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("masonryObserver.dispose");
dotNetRef.Dispose();
}
}
}