769 lines
29 KiB
Twig
769 lines
29 KiB
Twig
{% extends 'base.html.twig' %}
|
|
{% block title %}Album Search{% endblock %}
|
|
{% block body %}
|
|
{% set query_value = query|default('') %}
|
|
{% set album_value = album|default('') %}
|
|
{% set artist_value = artist|default('') %}
|
|
{% set year_from_value = year_from|default('') %}
|
|
{% set year_to_value = year_to|default('') %}
|
|
{% set genre_value = genre|default('') %}
|
|
{% set source_value = source|default('all') %}
|
|
{% set user_only_value = source_value == 'user' %}
|
|
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
|
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
|
{% set landing_view = not has_search %}
|
|
|
|
{% if landing_view %}
|
|
<style>
|
|
.landing-search-form {
|
|
max-width: 640px;
|
|
margin: 0 auto;
|
|
}
|
|
.landing-search-input {
|
|
border-radius: 999px;
|
|
padding: 1rem 1.5rem;
|
|
font-size: 1.1rem;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
}
|
|
</style>
|
|
{% endif %}
|
|
|
|
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
|
|
{% if source_value != 'user' and externalConfigured is defined and not externalConfigured %}
|
|
<div class="alert alert-info">
|
|
Last.fm is not configured yet. Results will only include user-created albums.
|
|
{% if is_granted('ROLE_ADMIN') %}
|
|
<a class="alert-link" href="{{ path('admin_settings') }}">Enter Last.fm API key</a>.
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
<form class="{{ landing_view ? 'landing-search-form mb-4' : 'row g-2 mb-2 align-items-center' }}" action="{{ path('album_search') }}" method="get">
|
|
{% if landing_view %}
|
|
<div>
|
|
<input class="form-control form-control-lg landing-search-input" type="search" name="q" value="{{ query_value }}" placeholder="Search albums, artists..." autocomplete="off" />
|
|
</div>
|
|
<div class="d-flex justify-content-center gap-3 mt-3">
|
|
<button class="btn btn-success px-4" type="submit">Search</button>
|
|
<button class="btn btn-outline-secondary px-4" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="{{ advanced_open ? 'true' : 'false' }}" aria-controls="advancedSearch">Advanced</button>
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="collapse {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
|
<div class="row g-2">
|
|
<div class="col-sm-4 order-sm-4 d-flex align-items-center">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="userOnlyToggleLanding" name="user_only" value="1" {{ user_only_value ? 'checked' : '' }}>
|
|
<label class="form-check-label" for="userOnlyToggleLanding">Show only user-created content</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
|
</div>
|
|
<div class="col-sm-2">
|
|
<input class="form-control" type="date" name="year_from" value="{{ year_from_value }}" />
|
|
</div>
|
|
<div class="col-sm-2">
|
|
<input class="form-control" type="date" name="year_to" value="{{ year_to_value }}" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="col-sm">
|
|
<input class="form-control" type="search" name="q" value="{{ query_value }}" placeholder="Search.." autocomplete="off" />
|
|
</div>
|
|
<div class="col-auto d-flex gap-2">
|
|
<button class="btn btn-success" type="submit">Search</button>
|
|
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="{{ advanced_open ? 'true' : 'false' }}" aria-controls="advancedSearch">Advanced</button>
|
|
</div>
|
|
<div class="collapse col-12 {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
|
<div class="row g-2 mt-1">
|
|
<div class="col-sm-4 order-sm-4 d-flex align-items-center">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="userOnlyToggleInline" name="user_only" value="1" {{ user_only_value ? 'checked' : '' }}>
|
|
<label class="form-check-label" for="userOnlyToggleInline">Show only user-created content</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
|
</div>
|
|
<div class="col-sm-4">
|
|
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
|
</div>
|
|
<div class="col-sm-2">
|
|
<input class="form-control" type="date" name="year_from" value="{{ year_from_value }}" />
|
|
</div>
|
|
<div class="col-sm-2">
|
|
<input class="form-control" type="date" name="year_to" value="{{ year_to_value }}" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</form>
|
|
|
|
{% if albums is defined and albums|length > 0 %}
|
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3" data-album-grid="1">
|
|
{% for album in albums %}
|
|
{% include 'album/_search_card.html.twig' with { album: album, stats: stats, savedIds: savedIds } %}
|
|
{% endfor %}
|
|
</div>
|
|
{% if has_search %}
|
|
<div class="d-flex justify-content-center mt-4" data-show-more-wrap="1">
|
|
<button class="btn btn-outline-secondary" type="button" data-show-more-btn="1">Show More</button>
|
|
</div>
|
|
{% endif %}
|
|
{% elseif query or album or artist or genre or year_from or year_to %}
|
|
<p>No albums found.</p>
|
|
{% endif %}
|
|
|
|
{% if albums is defined and albums|length > 0 %}
|
|
{% set card_data_url_template = path('album_card_data', {id: '__ALBUM_ID__'}) %}
|
|
{% set show_url_template = path('album_show', {id: '__ALBUM_ID__'}) %}
|
|
{% set batch_url = path('album_search_batch') %}
|
|
<script>
|
|
(() => {
|
|
const albumGrid = document.querySelector('[data-album-grid="1"]');
|
|
if (!albumGrid) {
|
|
return;
|
|
}
|
|
|
|
const endpointTemplate = {{ card_data_url_template|json_encode|raw }};
|
|
const showUrlTemplate = {{ show_url_template|json_encode|raw }};
|
|
const batchEndpoint = {{ batch_url|json_encode|raw }};
|
|
const hasSearch = {{ has_search ? 'true' : 'false' }};
|
|
const baseLimitRaw = Number({{ search_limit|default(20) }});
|
|
const baseLimit = Number.isFinite(baseLimitRaw) && baseLimitRaw > 0 ? Math.floor(baseLimitRaw) : 20;
|
|
const searchFilters = {
|
|
q: {{ query_value|json_encode|raw }},
|
|
album: {{ album_value|json_encode|raw }},
|
|
artist: {{ artist_value|json_encode|raw }},
|
|
genre: {{ genre_value|json_encode|raw }},
|
|
year_from: {{ year_from_value|json_encode|raw }},
|
|
year_to: {{ year_to_value|json_encode|raw }},
|
|
source: {{ source_value|json_encode|raw }},
|
|
user_only: {{ (user_only_value ? '1' : '')|json_encode|raw }},
|
|
};
|
|
const showMoreWrap = document.querySelector('[data-show-more-wrap="1"]');
|
|
const showMoreButton = document.querySelector('[data-show-more-btn="1"]');
|
|
|
|
const maxConcurrent = 3;
|
|
let active = 0;
|
|
let pendingHydrationCount = 0;
|
|
let batchLoading = false;
|
|
let exhausted = !hasSearch;
|
|
let targetVisibleCount = hasSearch ? baseLimit : albumGrid.querySelectorAll('.js-album-card').length;
|
|
let nextOffset = albumGrid.querySelectorAll('.js-album-card').length;
|
|
const pendingQueue = [];
|
|
const enqueuedCards = new WeakSet();
|
|
const knownAlbumIds = new Set();
|
|
let highResObserver = null;
|
|
let hydrationObserver = null;
|
|
|
|
const buildUrl = (template, albumId) => template.replace('__ALBUM_ID__', encodeURIComponent(albumId));
|
|
|
|
const countVisibleCards = () => albumGrid.querySelectorAll('.js-album-card').length;
|
|
|
|
const updateShowMoreButton = () => {
|
|
if (!showMoreWrap || !showMoreButton || !hasSearch) {
|
|
return;
|
|
}
|
|
|
|
if (exhausted) {
|
|
showMoreWrap.classList.add('d-none');
|
|
return;
|
|
}
|
|
|
|
showMoreWrap.classList.remove('d-none');
|
|
showMoreButton.disabled = batchLoading;
|
|
showMoreButton.textContent = batchLoading ? 'Loading...' : 'Show More';
|
|
};
|
|
|
|
const resolveImageVariants = (url) => {
|
|
const normalized = typeof url === 'string' ? url.trim() : '';
|
|
if (normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
if (normalized.includes('/i/u/300x300/')) {
|
|
return {
|
|
previewUrl: normalized.replace('/i/u/300x300/', '/i/u/174s/'),
|
|
highresUrl: normalized,
|
|
};
|
|
}
|
|
|
|
if (normalized.includes('/front-500')) {
|
|
return {
|
|
previewUrl: normalized.replace('/front-500', '/front-250'),
|
|
highresUrl: '',
|
|
};
|
|
}
|
|
|
|
if (normalized.includes('/front-1200')) {
|
|
return {
|
|
previewUrl: normalized.replace('/front-1200', '/front-250'),
|
|
highresUrl: '',
|
|
};
|
|
}
|
|
|
|
if (/\/front$/.test(normalized)) {
|
|
return {
|
|
previewUrl: `${normalized}-250`,
|
|
highresUrl: '',
|
|
};
|
|
}
|
|
|
|
return {
|
|
previewUrl: normalized,
|
|
highresUrl: '',
|
|
};
|
|
};
|
|
|
|
const removeCard = (card) => {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const column = card.closest('[data-card-col]') || card.closest('.col');
|
|
if (column) {
|
|
column.remove();
|
|
} else {
|
|
card.remove();
|
|
}
|
|
|
|
setTimeout(() => {
|
|
ensureTargetVisible();
|
|
}, 0);
|
|
};
|
|
|
|
const setHydrationStatus = (card, message) => {
|
|
const status = card.querySelector('.js-hydration-status');
|
|
if (status) {
|
|
status.textContent = message;
|
|
}
|
|
};
|
|
|
|
const showCoverNotFound = (card) => {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const imageLink = card.querySelector('.js-image-link');
|
|
if (!imageLink) {
|
|
return;
|
|
}
|
|
|
|
let imageShell = card.querySelector('.js-image-shell');
|
|
if (!imageShell) {
|
|
imageShell = document.createElement('div');
|
|
imageShell.className = 'card-img-top js-image-shell d-flex align-items-center justify-content-center text-secondary';
|
|
imageShell.style.width = '100%';
|
|
imageShell.style.height = '220px';
|
|
imageShell.style.objectFit = 'cover';
|
|
imageShell.style.background = '#f3edf0';
|
|
|
|
const imageTag = card.querySelector('.js-card-image');
|
|
if (imageTag) {
|
|
imageTag.replaceWith(imageShell);
|
|
} else {
|
|
imageLink.appendChild(imageShell);
|
|
}
|
|
}
|
|
|
|
imageShell.textContent = 'Cover not found';
|
|
};
|
|
|
|
const attachImageFallback = (imageTag) => {
|
|
if (!imageTag) {
|
|
return;
|
|
}
|
|
if (imageTag.dataset.fallbackBound === '1') {
|
|
return;
|
|
}
|
|
|
|
const card = imageTag.closest('.js-album-card');
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
imageTag.addEventListener('error', () => {
|
|
showCoverNotFound(card);
|
|
}, { once: true });
|
|
imageTag.dataset.fallbackBound = '1';
|
|
};
|
|
|
|
const upgradeToHighRes = (imageTag) => {
|
|
if (!imageTag) {
|
|
return;
|
|
}
|
|
|
|
const highres = typeof imageTag.dataset.highres === 'string' ? imageTag.dataset.highres.trim() : '';
|
|
if (highres === '' || imageTag.src === highres) {
|
|
return;
|
|
}
|
|
|
|
imageTag.src = highres;
|
|
};
|
|
|
|
const observeHighResImage = (imageTag) => {
|
|
if (!imageTag) {
|
|
return;
|
|
}
|
|
|
|
const highres = typeof imageTag.dataset.highres === 'string' ? imageTag.dataset.highres.trim() : '';
|
|
if (highres === '') {
|
|
return;
|
|
}
|
|
|
|
if (!('IntersectionObserver' in window)) {
|
|
upgradeToHighRes(imageTag);
|
|
return;
|
|
}
|
|
|
|
if (!highResObserver) {
|
|
highResObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) {
|
|
return;
|
|
}
|
|
|
|
const image = entry.target;
|
|
if (image instanceof HTMLImageElement) {
|
|
upgradeToHighRes(image);
|
|
}
|
|
highResObserver.unobserve(entry.target);
|
|
});
|
|
}, {
|
|
rootMargin: '250px 0px',
|
|
threshold: 0.01,
|
|
});
|
|
}
|
|
|
|
highResObserver.observe(imageTag);
|
|
};
|
|
|
|
const applyCardImage = (imageTag, imageUrl, albumName) => {
|
|
const variants = resolveImageVariants(imageUrl);
|
|
if (!variants) {
|
|
return false;
|
|
}
|
|
|
|
imageTag.src = variants.previewUrl;
|
|
imageTag.alt = `${albumName || 'Album'} cover`;
|
|
if (variants.highresUrl !== '') {
|
|
imageTag.dataset.highres = variants.highresUrl;
|
|
observeHighResImage(imageTag);
|
|
} else {
|
|
delete imageTag.dataset.highres;
|
|
}
|
|
attachImageFallback(imageTag);
|
|
return true;
|
|
};
|
|
|
|
const setCardOpenable = (card, showUrl) => {
|
|
const reviewLink = card.querySelector('.js-reviews-link');
|
|
if (reviewLink) {
|
|
reviewLink.href = showUrl;
|
|
reviewLink.classList.remove('disabled');
|
|
reviewLink.removeAttribute('aria-disabled');
|
|
}
|
|
|
|
const titleLink = card.querySelector('.js-title-link');
|
|
if (titleLink) {
|
|
titleLink.href = showUrl;
|
|
titleLink.classList.remove('disabled', 'pe-none');
|
|
titleLink.removeAttribute('aria-disabled');
|
|
}
|
|
|
|
const imageLink = card.querySelector('.js-image-link');
|
|
if (imageLink) {
|
|
imageLink.href = showUrl;
|
|
imageLink.classList.remove('disabled', 'pe-none');
|
|
imageLink.removeAttribute('aria-disabled');
|
|
}
|
|
};
|
|
|
|
const hydrateCard = async (card) => {
|
|
const albumId = String(card.dataset.albumId || '').trim();
|
|
if (albumId === '') {
|
|
removeCard(card);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(buildUrl(endpointTemplate, albumId), {
|
|
headers: { 'Accept': 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
setHydrationStatus(card, 'Could not load details.');
|
|
showCoverNotFound(card);
|
|
return;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
if (!payload || payload.ok !== true || payload.can_open !== true || !payload.album) {
|
|
removeCard(card);
|
|
return;
|
|
}
|
|
|
|
const hydratedAlbum = payload.album;
|
|
const hydratedId = String(hydratedAlbum.id || albumId).trim() || albumId;
|
|
const showUrl = buildUrl(showUrlTemplate, hydratedId);
|
|
setCardOpenable(card, showUrl);
|
|
|
|
const titleLink = card.querySelector('.js-title-link');
|
|
if (titleLink && typeof hydratedAlbum.name === 'string' && hydratedAlbum.name.trim() !== '') {
|
|
titleLink.textContent = hydratedAlbum.name;
|
|
}
|
|
|
|
const artistLine = card.querySelector('.js-artist-line');
|
|
if (artistLine && Array.isArray(hydratedAlbum.artists)) {
|
|
const artistNames = hydratedAlbum.artists
|
|
.map((artist) => (artist && typeof artist.name === 'string' ? artist.name.trim() : ''))
|
|
.filter((name) => name !== '');
|
|
artistLine.textContent = artistNames.join(', ');
|
|
}
|
|
|
|
const releaseLine = card.querySelector('.js-release-line');
|
|
if (releaseLine) {
|
|
const releaseDate = typeof hydratedAlbum.release_date === 'string' && hydratedAlbum.release_date.trim() !== ''
|
|
? hydratedAlbum.release_date.trim()
|
|
: 'Unknown release date';
|
|
const totalTracks = Number.isFinite(Number(hydratedAlbum.total_tracks))
|
|
? Math.max(0, Number(hydratedAlbum.total_tracks))
|
|
: 0;
|
|
releaseLine.textContent = `Released ${releaseDate} • ${totalTracks} tracks`;
|
|
}
|
|
|
|
const genreLine = card.querySelector('.js-genre-line');
|
|
if (genreLine) {
|
|
const genres = Array.isArray(hydratedAlbum.genres)
|
|
? hydratedAlbum.genres.filter((genre) => typeof genre === 'string' && genre.trim() !== '')
|
|
: [];
|
|
if (genres.length > 0) {
|
|
const genreText = `Genre: ${genres.join(', ')}`;
|
|
const genreSmall = genreLine.querySelector('small');
|
|
if (genreSmall) {
|
|
genreSmall.textContent = genreText;
|
|
} else {
|
|
genreLine.textContent = genreText;
|
|
}
|
|
genreLine.classList.remove('d-none');
|
|
} else {
|
|
genreLine.textContent = '';
|
|
genreLine.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
const imageTag = card.querySelector('.js-card-image');
|
|
if (imageTag && Array.isArray(hydratedAlbum.images) && hydratedAlbum.images.length > 0) {
|
|
const firstImage = hydratedAlbum.images[0];
|
|
if (firstImage && typeof firstImage.url === 'string' && firstImage.url.trim() !== '') {
|
|
const applied = applyCardImage(imageTag, firstImage.url, hydratedAlbum.name);
|
|
if (!applied) {
|
|
showCoverNotFound(card);
|
|
}
|
|
} else {
|
|
showCoverNotFound(card);
|
|
}
|
|
} else if (Array.isArray(hydratedAlbum.images) && hydratedAlbum.images.length > 0) {
|
|
const firstImage = hydratedAlbum.images[0];
|
|
const imageLink = card.querySelector('.js-image-link');
|
|
const imageShell = card.querySelector('.js-image-shell');
|
|
if (imageLink && imageShell && firstImage && typeof firstImage.url === 'string' && firstImage.url.trim() !== '') {
|
|
const img = document.createElement('img');
|
|
img.className = 'card-img-top js-card-image';
|
|
img.loading = 'lazy';
|
|
img.decoding = 'async';
|
|
img.style.width = '100%';
|
|
img.style.height = '220px';
|
|
img.style.objectFit = 'cover';
|
|
img.style.background = '#f3edf0';
|
|
const applied = applyCardImage(img, firstImage.url, hydratedAlbum.name);
|
|
if (!applied) {
|
|
showCoverNotFound(card);
|
|
return;
|
|
}
|
|
imageShell.replaceWith(img);
|
|
} else {
|
|
showCoverNotFound(card);
|
|
}
|
|
} else {
|
|
showCoverNotFound(card);
|
|
}
|
|
|
|
const stats = payload.stats && typeof payload.stats === 'object'
|
|
? payload.stats
|
|
: { avg: 0, count: 0 };
|
|
const avg = Number.isFinite(Number(stats.avg)) ? Number(stats.avg) : 0;
|
|
const count = Number.isFinite(Number(stats.count)) ? Number(stats.count) : 0;
|
|
const scoreLine = card.querySelector('.js-score-line');
|
|
if (scoreLine) {
|
|
const scoreText = `User score: ${avg}/10 (${count})`;
|
|
const scoreSmall = scoreLine.querySelector('small');
|
|
if (scoreSmall) {
|
|
scoreSmall.textContent = scoreText;
|
|
} else {
|
|
scoreLine.textContent = scoreText;
|
|
}
|
|
}
|
|
|
|
const openSourceButton = card.querySelector('.js-open-source');
|
|
const sourceUrl = hydratedAlbum.external_urls && typeof hydratedAlbum.external_urls.external === 'string'
|
|
? hydratedAlbum.external_urls.external.trim()
|
|
: '';
|
|
if (openSourceButton) {
|
|
if (sourceUrl !== '') {
|
|
openSourceButton.href = sourceUrl;
|
|
openSourceButton.classList.remove('d-none');
|
|
} else {
|
|
openSourceButton.remove();
|
|
}
|
|
} else if (sourceUrl !== '') {
|
|
const reviewsButton = card.querySelector('.js-reviews-link');
|
|
if (reviewsButton && reviewsButton.parentElement) {
|
|
const anchor = document.createElement('a');
|
|
anchor.className = 'btn btn-outline-success btn-sm js-open-source me-2';
|
|
anchor.href = sourceUrl;
|
|
anchor.target = '_blank';
|
|
anchor.rel = 'noopener';
|
|
anchor.textContent = 'Open Source';
|
|
reviewsButton.parentElement.insertBefore(anchor, reviewsButton);
|
|
}
|
|
}
|
|
|
|
const saveForm = card.querySelector('.js-save-form');
|
|
if (saveForm) {
|
|
if (payload.saved === true) {
|
|
saveForm.classList.add('d-none');
|
|
} else {
|
|
saveForm.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
setHydrationStatus(card, '');
|
|
const status = card.querySelector('.js-hydration-status');
|
|
if (status) {
|
|
status.remove();
|
|
}
|
|
card.dataset.hydrate = '0';
|
|
} catch (error) {
|
|
setHydrationStatus(card, 'Could not load details.');
|
|
showCoverNotFound(card);
|
|
}
|
|
};
|
|
|
|
const runQueue = () => {
|
|
while (active < maxConcurrent && pendingQueue.length > 0) {
|
|
const card = pendingQueue.shift();
|
|
if (!card) {
|
|
continue;
|
|
}
|
|
active += 1;
|
|
hydrateCard(card).finally(() => {
|
|
active -= 1;
|
|
pendingHydrationCount = Math.max(0, pendingHydrationCount - 1);
|
|
runQueue();
|
|
ensureTargetVisible();
|
|
});
|
|
}
|
|
};
|
|
|
|
const enqueueCard = (card) => {
|
|
if (!card || enqueuedCards.has(card) || card.dataset.hydrate !== '1') {
|
|
return;
|
|
}
|
|
enqueuedCards.add(card);
|
|
pendingHydrationCount += 1;
|
|
pendingQueue.push(card);
|
|
runQueue();
|
|
};
|
|
|
|
const observeHydrationCard = (card) => {
|
|
if (!card || card.dataset.hydrate !== '1') {
|
|
return;
|
|
}
|
|
|
|
if (!('IntersectionObserver' in window)) {
|
|
enqueueCard(card);
|
|
return;
|
|
}
|
|
|
|
if (!hydrationObserver) {
|
|
hydrationObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) {
|
|
return;
|
|
}
|
|
enqueueCard(entry.target);
|
|
hydrationObserver.unobserve(entry.target);
|
|
});
|
|
}, {
|
|
rootMargin: '350px 0px',
|
|
threshold: 0.01,
|
|
});
|
|
}
|
|
|
|
hydrationObserver.observe(card);
|
|
};
|
|
|
|
const registerCard = (card) => {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const albumId = String(card.dataset.albumId || '').trim();
|
|
if (albumId !== '') {
|
|
knownAlbumIds.add(albumId);
|
|
}
|
|
|
|
const cardImages = Array.from(card.querySelectorAll('.js-card-image'));
|
|
cardImages.forEach((imageTag) => {
|
|
attachImageFallback(imageTag);
|
|
observeHighResImage(imageTag);
|
|
});
|
|
|
|
observeHydrationCard(card);
|
|
};
|
|
|
|
const buildBatchRequestUrl = (limit) => {
|
|
const params = new URLSearchParams();
|
|
|
|
const appendIfPresent = (key, value) => {
|
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
if (normalized !== '') {
|
|
params.set(key, normalized);
|
|
}
|
|
};
|
|
|
|
appendIfPresent('q', searchFilters.q);
|
|
appendIfPresent('album', searchFilters.album);
|
|
appendIfPresent('artist', searchFilters.artist);
|
|
appendIfPresent('genre', searchFilters.genre);
|
|
appendIfPresent('year_from', searchFilters.year_from);
|
|
appendIfPresent('year_to', searchFilters.year_to);
|
|
if (searchFilters.user_only === '1') {
|
|
params.set('user_only', '1');
|
|
} else if (searchFilters.source !== '' && searchFilters.source !== 'all') {
|
|
params.set('source', searchFilters.source);
|
|
}
|
|
|
|
params.set('offset', String(nextOffset));
|
|
params.set('limit', String(limit));
|
|
return `${batchEndpoint}?${params.toString()}`;
|
|
};
|
|
|
|
const appendBatchHtml = (html) => {
|
|
if (typeof html !== 'string' || html.trim() === '') {
|
|
return 0;
|
|
}
|
|
|
|
const template = document.createElement('template');
|
|
template.innerHTML = html.trim();
|
|
const incomingColumns = Array.from(template.content.querySelectorAll('[data-card-col]'));
|
|
let appendedCount = 0;
|
|
|
|
incomingColumns.forEach((column) => {
|
|
const card = column.querySelector('.js-album-card');
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const albumId = String(card.dataset.albumId || '').trim();
|
|
if (albumId !== '' && knownAlbumIds.has(albumId)) {
|
|
return;
|
|
}
|
|
|
|
albumGrid.appendChild(column);
|
|
registerCard(card);
|
|
appendedCount += 1;
|
|
});
|
|
|
|
return appendedCount;
|
|
};
|
|
|
|
const fetchBatch = async (requestLimit) => {
|
|
if (!hasSearch || batchLoading || exhausted) {
|
|
return;
|
|
}
|
|
|
|
const safeLimit = Math.max(1, Math.min(baseLimit, requestLimit));
|
|
batchLoading = true;
|
|
updateShowMoreButton();
|
|
|
|
try {
|
|
const response = await fetch(buildBatchRequestUrl(safeLimit), {
|
|
headers: { 'Accept': 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const returnedCountRaw = Number(payload.count);
|
|
const returnedCount = Number.isFinite(returnedCountRaw) ? Math.max(0, Math.floor(returnedCountRaw)) : 0;
|
|
const nextOffsetRaw = Number(payload.next_offset);
|
|
if (Number.isFinite(nextOffsetRaw) && nextOffsetRaw >= 0) {
|
|
nextOffset = Math.max(nextOffset, Math.floor(nextOffsetRaw));
|
|
} else {
|
|
nextOffset += returnedCount;
|
|
}
|
|
|
|
appendBatchHtml(typeof payload.html === 'string' ? payload.html : '');
|
|
|
|
if (returnedCount === 0 || payload.exhausted === true || returnedCount < safeLimit) {
|
|
exhausted = true;
|
|
}
|
|
} catch (error) {
|
|
// Keep current state and allow retries.
|
|
} finally {
|
|
batchLoading = false;
|
|
updateShowMoreButton();
|
|
ensureTargetVisible();
|
|
}
|
|
};
|
|
|
|
const ensureTargetVisible = () => {
|
|
if (!hasSearch || exhausted || batchLoading) {
|
|
updateShowMoreButton();
|
|
return;
|
|
}
|
|
|
|
if (pendingHydrationCount > 0) {
|
|
return;
|
|
}
|
|
|
|
const visibleCards = countVisibleCards();
|
|
if (visibleCards >= targetVisibleCount) {
|
|
return;
|
|
}
|
|
|
|
const missing = targetVisibleCount - visibleCards;
|
|
const nextLimit = Math.max(1, Math.min(baseLimit, missing));
|
|
fetchBatch(nextLimit);
|
|
};
|
|
|
|
const initialCards = Array.from(albumGrid.querySelectorAll('.js-album-card'));
|
|
initialCards.forEach((card) => registerCard(card));
|
|
|
|
if (showMoreButton) {
|
|
showMoreButton.addEventListener('click', () => {
|
|
targetVisibleCount += baseLimit;
|
|
ensureTargetVisible();
|
|
});
|
|
}
|
|
|
|
updateShowMoreButton();
|
|
ensureTargetVisible();
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|