Files
MilkyShots/MilkStream.Client/Components/Pages/Home.razor
REDCODE 2f7cd3d7da fix: resolve UI issues #61, #62, #66
- #61: wrap standalone fallback div in cosplayer-card-img-wrap on
  Cosplayers page so placeholder icon gets proper sizing/positioning
- #62: add mobile breakpoint rule for modal-overlay so edit form
  appears centered on small screens instead of at page bottom
- #66: make cosplayer name in ImagePreview, AlbumDetail, AlbumCard,
  and Home asset tiles a clickable link to /cosplayer/{id}
- add CosplayerIds to AssetPreviewDto and populate via mapper for
  linked navigation from the home page image previewer
2026-07-09 22:36:07 +02:00

281 lines
10 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-2 m-sm-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 @ref="topSentinelRef" class="masonry-sentinel"></div>
@if (isLoadingUp) {
<LoadSpinner/>
}
<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>
}
@if (asset.CosplayerNames?.Count > 0 || asset.AlbumNames?.Count > 0) {
<div class="tile-info">
@if (asset.CosplayerNames?.Count > 0) {
<span class="tile-info-cosplayer">@string.Join(", ", asset.CosplayerNames)</span>
}
@if (asset.AlbumNames?.Count > 0) {
var albumIdx = 0;
@foreach (var albumName in asset.AlbumNames) {
var albumId = asset.AlbumIds != null && albumIdx < asset.AlbumIds.Count ? asset.AlbumIds[albumIdx] : (Guid?)null;
albumIdx++;
@if (albumId.HasValue) {
<a class="tile-info-album" href="/albums/@albumId.Value" @onclick:stopPropagation="true">@albumName</a>
} else {
<span class="tile-info-album">@albumName</span>
}
@if (albumIdx < asset.AlbumNames.Count) {
<text> </text>
}
}
}
</div>
}
</div>
}
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
}
@if (selectedAsset != null) {
<ImagePreview Show="true"
AssetId="selectedAsset.Id"
HasPreview="selectedAsset.HasPreview"
HasPrevious="selectedIndex > 0"
HasNext="selectedIndex < flatList.Count - 1"
Filename="@selectedAsset.FileName"
OnNavigate="NavigatePreview"
OnClose="ClosePreview">
@if (selectedAsset.CosplayerNames?.Count > 0) {
@for (var cIdx = 0; cIdx < selectedAsset.CosplayerNames.Count; cIdx++) {
var cName = selectedAsset.CosplayerNames[cIdx];
var cId = selectedAsset.CosplayerIds != null && cIdx < selectedAsset.CosplayerIds.Count ? selectedAsset.CosplayerIds[cIdx] : (Guid?)null;
@if (cId.HasValue) {
<a class="preview-info-cosplayer" href="/cosplayer/@cId.Value" @onclick:stopPropagation="true">@cName</a>
} else {
<span class="preview-info-cosplayer">@cName</span>
}
}
}
@if (selectedAsset.AlbumNames?.Count > 0) {
var albumIdx = 0;
@foreach (var albumName in selectedAsset.AlbumNames) {
var albumId = selectedAsset.AlbumIds != null && albumIdx < selectedAsset.AlbumIds.Count ? selectedAsset.AlbumIds[albumIdx] : (Guid?)null;
albumIdx++;
@if (albumId.HasValue) {
<a class="preview-info-album" href="/albums/@albumId.Value" @onclick:stopPropagation="true">@albumName</a>
} else {
<span class="preview-info-album">@albumName</span>
}
@if (albumIdx < selectedAsset.AlbumNames.Count) {
<text> </text>
}
}
}
</ImagePreview>
}
@code {
List<List<AssetPreviewDto>> allAssetPages = [];
List<AssetPreviewDto> flatList = [];
int currentPage = 1;
int loadedMinPage = 1;
int loadedMaxPage = 1;
bool hasMore = true;
bool hasMoreUp;
bool isLoading = true;
bool isLoadingMore;
bool isLoadingUp;
AssetPreviewDto? selectedAsset;
int selectedIndex = -1;
ElementReference sentinelRef;
ElementReference topSentinelRef;
DotNetObjectReference<Home>? dotNetRef;
Guid _randomSeed;
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 async override Task OnAfterRenderAsync(bool firstRender) {
if (flatList.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"masonryObserver.observeBottom", sentinelRef, dotNetRef
);
await jsRuntime.InvokeVoidAsync(
"masonryObserver.observeTop", topSentinelRef, dotNetRef
);
}
if (selectedAsset != null)
await jsRuntime.InvokeVoidAsync("masonryObserver.lockBodyScroll");
else
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
}
async Task LoadFirstPage() {
_randomSeed = Guid.NewGuid();
isLoading = true;
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 1, 30);
if (items?.Count > 0) {
allAssetPages.Add(items);
RebuildFlatList();
loadedMinPage = 1;
loadedMaxPage = 1;
currentPage = 2;
hasMoreUp = false;
}
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, _randomSeed, currentPage, 30);
if (items?.Count > 0) {
allAssetPages.Add(items);
loadedMaxPage = currentPage;
currentPage++;
hasMoreUp = true;
RebuildFlatList();
if (allAssetPages.Count > 10) {
allAssetPages.RemoveAt(0);
loadedMinPage++;
}
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
[JSInvokable]
public async Task LoadPreviousPage() {
if (isLoadingUp || !hasMoreUp || loadedMinPage <= 1) return;
isLoadingUp = true;
StateHasChanged();
var pageToLoad = loadedMinPage - 1;
if (pageToLoad < 1) {
hasMoreUp = false;
isLoadingUp = false;
return;
}
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30);
if (items?.Count > 0) {
allAssetPages.Insert(0, items);
loadedMinPage = pageToLoad;
RebuildFlatList();
if (allAssetPages.Count > 10) {
allAssetPages.RemoveAt(allAssetPages.Count - 1);
loadedMaxPage--;
}
}
hasMoreUp = loadedMinPage > 1;
isLoadingUp = 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;
}
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}";
public async ValueTask DisposeAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
if (dotNetRef != null) {
await jsRuntime.InvokeVoidAsync("masonryObserver.dispose");
dotNetRef.Dispose();
}
}
}