Revert "refactor: replace custom masonry JS with Bootstrap grid + Masonry.js approach"

This reverts commit 04d425b88d.
This commit is contained in:
MrFastwind
2026-07-09 02:07:16 +02:00
parent 04d425b88d
commit debd0dd4af
10 changed files with 298 additions and 162 deletions

View File

@@ -62,12 +62,12 @@
<h4 class="text-info">No assets in this album</h4>
</div>
} else {
<div class="@(viewMode == "masonry" ? "row g-2 album-assets-masonry" : "album-assets-grid grid")">
<div class="@("album-assets-grid " + viewMode)">
@for (int i = 0; i < album.Images.Count; i++) {
var assetId = album.Images[i];
var preview = album.AssetPreviews.Count > i ? album.AssetPreviews[i] : null;
var index = i;
<div class="@(viewMode == "masonry" ? "col-6 col-sm-4 col-lg-3 col-xxl-2" : "") masonry-tile" @key="assetId"
<div class="masonry-tile" @key="assetId"
style="aspect-ratio:@GetAspectRatio(preview)"
@onclick="() => OpenPreview(index)">
@if (preview == null || preview.HasThumbnail) {
@@ -128,10 +128,9 @@
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string viewMode = "masonry";
bool _masonryInited;
void SetMasonry() { viewMode = "masonry"; _masonryInited = false; StateHasChanged(); }
void SetGrid() { viewMode = "grid"; _masonryInited = false; StateHasChanged(); }
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
AlbumAssetPreviewDto? currentPreview => album?.AssetPreviews != null && selectedIndex >= 0 && selectedIndex < album.AssetPreviews.Count
? album.AssetPreviews[selectedIndex]
@@ -144,16 +143,8 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (viewMode == "masonry" && album?.Images.Count > 0) {
if (!_masonryInited) {
await jsRuntime.InvokeVoidAsync("masonryInit", ".album-assets-masonry");
_masonryInited = true;
} else {
await jsRuntime.InvokeVoidAsync("masonryReload", ".album-assets-masonry");
}
} else {
_masonryInited = false;
await jsRuntime.InvokeVoidAsync("masonryDestroy", ".album-assets-masonry");
if (album != null && album.Images.Count > 0 && viewMode == "masonry") {
await jsRuntime.InvokeVoidAsync("masonryLayout.apply", ".album-assets-grid.masonry");
}
}

View File

@@ -36,16 +36,29 @@
}
}
/* ── Masonry ── */
.album-assets-grid.masonry {
position: relative;
width: 100%;
}
/* ── Grid ── */
.album-assets-grid.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
padding: 0 8px;
position: static !important;
height: auto !important;
}
.album-assets-grid.grid .masonry-tile {
aspect-ratio: 1;
aspect-ratio: 1 !important;
position: relative !important;
left: auto !important;
top: auto !important;
width: auto !important;
height: auto !important;
}
/* ── Tile shared ── */
@@ -57,6 +70,10 @@
transition: filter 0.2s;
}
.album-assets-grid.grid .masonry-tile {
position: relative;
}
.masonry-tile:hover {
filter: brightness(1.15);
}

View File

@@ -49,9 +49,9 @@
</div>
</div>
<div class="@(viewMode == "masonry" ? "row g-2 album-grid-masonry" : "album-grid grid")">
<div class="@("album-grid " + viewMode)">
@foreach (var album in albums) {
<div class="@(viewMode == "masonry" ? "col-6 col-sm-4 col-lg-3 col-xxl-2" : "") album-card" @key="album.Id"
<div class="album-card" @key="album.Id"
@onclick="() => NavigateToAlbum(album.Id)"
@onclick:preventDefault="true">
@if (album.CoverAssetId.HasValue) {
@@ -104,18 +104,10 @@
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string viewMode = "masonry";
bool _masonryInited;
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
void SetMasonry() {
viewMode = "masonry";
_masonryInited = false;
}
void SetGrid() {
viewMode = "grid";
_masonryInited = false;
}
void SetMasonry() => viewMode = "masonry";
void SetGrid() => viewMode = "grid";
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
@@ -131,18 +123,6 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (viewMode == "masonry" && albums.Count > 0) {
if (!_masonryInited) {
await jsRuntime.InvokeVoidAsync("masonryInit", ".album-grid-masonry");
_masonryInited = true;
} else {
await jsRuntime.InvokeVoidAsync("masonryReload", ".album-grid-masonry");
}
} else {
_masonryInited = false;
await jsRuntime.InvokeVoidAsync("masonryDestroy", ".album-grid-masonry");
}
if (albums.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(

View File

@@ -1,3 +1,10 @@
/* ── Masonry ── */
.album-grid.masonry {
column-count: 4;
column-gap: 8px;
padding: 0 8px;
}
/* ── Grid ── */
.album-grid.grid {
display: grid;
@@ -6,10 +13,6 @@
padding: 0 8px;
}
.album-grid.grid .album-card {
aspect-ratio: 1;
}
/* ── Card shared ── */
.album-card {
overflow: hidden;
@@ -17,6 +20,7 @@
cursor: pointer;
background: var(--bs-dark, #1a1a1a);
transition: filter 0.2s, transform 0.2s;
position: relative;
}
.album-card:hover {
@@ -32,7 +36,6 @@
.album-card-fallback {
width: 100%;
aspect-ratio: 4 / 3;
display: flex;
align-items: center;
justify-content: center;
@@ -41,6 +44,20 @@
font-size: 2rem;
}
.album-grid.masonry .album-card {
break-inside: avoid;
margin-bottom: 8px;
}
.album-grid.grid .album-card {
aspect-ratio: 1;
}
.album-grid.masonry .album-card-fallback {
aspect-ratio: 4 / 3;
height: auto;
}
/* ── Info overlay ── */
.album-card-info {
position: absolute;
@@ -86,5 +103,15 @@
}
@media (max-width: 576px) {
.album-grid.masonry { column-count: 2; }
.album-grid.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}
@media (min-width: 577px) and (max-width: 992px) {
.album-grid.masonry { column-count: 3; }
}
@media (min-width: 993px) and (max-width: 1400px) {
.album-grid.masonry { column-count: 4; }
}
@media (min-width: 1401px) {
.album-grid.masonry { column-count: 5; }
}

View File

@@ -32,12 +32,12 @@
<LoadSpinner/>
}
<div class="row g-2">
<div class="masonry-grid">
@{ var idx = 0; }
@foreach (var page in allAssetPages) {
@foreach (var asset in page) {
var i = idx++;
<div class="col-6 col-sm-4 col-lg-3 col-xxl-2 masonry-tile" @key="asset.Id"
<div class="masonry-tile" @key="asset.Id"
style="aspect-ratio:@GetAspectRatio(asset)"
@onclick="() => OpenPreview(asset, i)">
@if (asset.HasThumbnail) {
@@ -149,21 +149,14 @@
}
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (flatList.Count > 0) {
if (firstRender) {
await jsRuntime.InvokeVoidAsync("masonryInit", ".row.g-2");
} else {
await jsRuntime.InvokeVoidAsync("masonryReload", ".row.g-2");
}
if (dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"masonryObserver.observeBottom", sentinelRef, dotNetRef
);
await jsRuntime.InvokeVoidAsync(
"masonryObserver.observeTop", topSentinelRef, dotNetRef
);
}
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)

View File

@@ -1,9 +1,20 @@
.masonry-grid {
column-count: 4;
column-gap: 8px;
padding: 8px;
}
.masonry-tile {
break-inside: avoid;
margin-bottom: 8px;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
background: var(--bs-dark, #1a1a1a);
transition: filter 0.2s;
position: relative;
content-visibility: auto;
contain-intrinsic-size: 200px;
}
.masonry-tile:hover {
@@ -74,3 +85,16 @@
.masonry-sentinel {
height: 1px;
}
@media (max-width: 576px) {
.masonry-grid { column-count: 2; }
}
@media (min-width: 577px) and (max-width: 992px) {
.masonry-grid { column-count: 3; }
}
@media (min-width: 993px) and (max-width: 1400px) {
.masonry-grid { column-count: 4; }
}
@media (min-width: 1401px) {
.masonry-grid { column-count: 5; }
}

View File

@@ -26,11 +26,8 @@
</div>
<script src="lib/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/masonry-layout@4.2.2/dist/masonry.pkgd.min.js"
integrity="sha384-GNFwBvfVxBkLMJpYMOABq3c+d3KnQxudP/mGPkzpZSTYykLBNsZEnG2D9G/X/+7D"
crossorigin="anonymous"></script>
<script src="js/infiniteScroll.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<script src="js/masonryObserver.js"></script>
</body>
</html>

View File

@@ -1,88 +0,0 @@
window.masonryObserver = {
_bottom: null,
_top: null,
_ref: null,
observeBottom(sentinel, dotNetRef) {
this._ref = dotNetRef;
this._bottom?.disconnect();
this._bottom = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) dotNetRef.invokeMethodAsync('LoadNextPage'); },
{ rootMargin: '600px' }
);
this._bottom.observe(sentinel);
},
observeTop(sentinel, dotNetRef) {
this._ref = dotNetRef;
this._top?.disconnect();
this._top = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) dotNetRef.invokeMethodAsync('LoadPreviousPage'); },
{ rootMargin: '600px' }
);
this._top.observe(sentinel);
},
dispose() {
this._bottom?.disconnect();
this._top?.disconnect();
this._bottom = null;
this._top = null;
this._ref = null;
},
lockBodyScroll() { document.body.style.overflow = 'hidden'; },
unlockBodyScroll() { document.body.style.overflow = ''; },
async downloadFile(url) {
const response = await fetch(url);
const blob = await response.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '';
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
},
};
window.albumObserver = {
_observer: null,
_ref: null,
observeBottom(sentinel, dotNetRef) {
this._ref = dotNetRef;
this._observer?.disconnect();
this._observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) dotNetRef.invokeMethodAsync('LoadMoreAlbums'); },
{ rootMargin: '600px' }
);
this._observer.observe(sentinel);
},
dispose() {
this._observer?.disconnect();
this._observer = null;
this._ref = null;
},
};
function masonryInit(selector, options) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!el || typeof Masonry === 'undefined') return false;
new Masonry(el, Object.assign({ percentPosition: true }, options));
return true;
}
function masonryReload(selector) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
const msnry = el ? Masonry.data(el) : null;
if (msnry) { msnry.reloadItems(); msnry.layout(); }
}
function masonryDestroy(selector) {
const el = typeof selector === 'string' ? document.querySelector(selector) : selector;
const msnry = el ? Masonry.data(el) : null;
if (msnry) { msnry.destroy(); }
}

View File

@@ -0,0 +1,203 @@
window.masonryObserver = {
bottomObserver: null,
topObserver: null,
dotNetRef: null,
observeBottom: function (sentinelElement, dotNetRef) {
this.dotNetRef = dotNetRef;
if (this.bottomObserver) this.bottomObserver.disconnect();
this.bottomObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
dotNetRef.invokeMethodAsync('LoadNextPage');
}
},
{ rootMargin: '600px' }
);
this.bottomObserver.observe(sentinelElement);
},
observeTop: function (sentinelElement, dotNetRef) {
this.dotNetRef = dotNetRef;
if (this.topObserver) this.topObserver.disconnect();
this.topObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
dotNetRef.invokeMethodAsync('LoadPreviousPage');
}
},
{ rootMargin: '600px' }
);
this.topObserver.observe(sentinelElement);
},
dispose: function () {
if (this.bottomObserver) {
this.bottomObserver.disconnect();
this.bottomObserver = null;
}
if (this.topObserver) {
this.topObserver.disconnect();
this.topObserver = null;
}
this.dotNetRef = null;
},
lockBodyScroll: function () {
document.body.style.overflow = 'hidden';
},
unlockBodyScroll: function () {
document.body.style.overflow = '';
},
downloadFile: async function (url) {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
};
window.albumObserver = {
bottomObserver: null,
dotNetRef: null,
observeBottom: function (sentinelElement, dotNetRef) {
this.dotNetRef = dotNetRef;
if (this.bottomObserver) this.bottomObserver.disconnect();
this.bottomObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
dotNetRef.invokeMethodAsync('LoadMoreAlbums');
}
},
{ rootMargin: '600px' }
);
this.bottomObserver.observe(sentinelElement);
},
dispose: function () {
if (this.bottomObserver) {
this.bottomObserver.disconnect();
this.bottomObserver = null;
}
this.dotNetRef = null;
}
};
window.pageLayout = {
columnCount: function () {
const w = window.innerWidth;
if (w <= 576) return 2;
if (w <= 992) return 3;
if (w <= 1400) return 4;
return 5;
}
};
window.masonryLayout = {
apply: function (containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) return;
const items = Array.from(container.children);
if (items.length === 0) return;
const gap = 8;
const cols = window.pageLayout.columnCount();
const containerWidth = container.clientWidth;
const colWidth = (containerWidth - gap * (cols - 1)) / cols;
container.style.position = 'relative';
const heights = new Array(cols).fill(0);
for (let i = 0; i < items.length; i++) {
const item = items[i];
item.style.position = 'absolute';
item.style.width = colWidth + 'px';
item.style.margin = '0';
// Force layout to get correct offsetHeight from aspect-ratio
item.offsetHeight;
let minIdx = 0;
for (let c = 1; c < cols; c++) {
if (heights[c] < heights[minIdx]) minIdx = c;
}
const left = minIdx * (colWidth + gap);
const top = heights[minIdx];
item.style.left = left + 'px';
item.style.top = top + 'px';
heights[minIdx] = top + item.offsetHeight + gap;
}
container.style.height = Math.max(...heights, 0) + 'px';
}
};
window.justifiedLayout = {
apply: function (containerSelector, gap, targetRowHeight) {
const container = document.querySelector(containerSelector);
if (!container) return;
const items = Array.from(container.children);
if (items.length === 0) return;
gap = gap || 8;
targetRowHeight = targetRowHeight || 250;
const containerWidth = container.clientWidth;
container.style.position = 'relative';
let top = 0;
let rowItems = [];
let rowAspectSum = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const ar = item.style.aspectRatio || '4/3';
const parts = ar.split('/');
const a = parts.length === 2 ? parseFloat(parts[0]) / parseFloat(parts[1]) : 4 / 3;
item.style.position = 'absolute';
rowItems.push({ item, aspect: a });
rowAspectSum += a;
const tentativeWidth = targetRowHeight * rowAspectSum;
const rowGaps = (rowItems.length - 1) * gap;
const isLastItem = i === items.length - 1;
const rowIsShy = isLastItem && tentativeWidth + rowGaps < containerWidth * 0.5;
if (tentativeWidth + rowGaps >= containerWidth || (isLastItem && !rowIsShy)) {
const availableWidth = containerWidth - rowGaps;
const actualHeight = rowIsShy ? targetRowHeight : availableWidth / rowAspectSum;
let left = 0;
for (const ri of rowItems) {
ri.item.style.left = left + 'px';
ri.item.style.top = top + 'px';
ri.item.style.width = (actualHeight * ri.aspect) + 'px';
ri.item.style.height = actualHeight + 'px';
left += actualHeight * ri.aspect + gap;
}
top += actualHeight + gap;
rowItems = [];
rowAspectSum = 0;
}
}
container.style.height = Math.max(top - gap, 0) + 'px';
}
};

View File

@@ -55,11 +55,3 @@ h1:focus {
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
/* Masonry: override col-xxl-2 (6 cols) to 5 cols at ≥1400px */
@media (min-width: 1400px) {
.row.g-2 > .col-xxl-2 {
flex: 0 0 auto;
width: 20%;
}
}