- #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
281 lines
10 KiB
Plaintext
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();
|
|
}
|
|
}
|
|
}
|