what the fuck
This commit is contained in:
1 parent
c1b1c2f372
commit
72a99a5bcd
69 files changed
+7519
-558
No files matched your search
@@ -8,6 +8,7 @@
|
||||
{% 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 %}
|
||||
@@ -48,12 +49,11 @@
|
||||
<div class="mt-3">
|
||||
<div class="collapse {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="external" {{ source_value == 'external' ? 'selected' : '' }}>Last.fm</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
<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" />
|
||||
@@ -65,10 +65,10 @@
|
||||
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
||||
<input class="form-control" type="date" name="year_from" value="{{ year_from_value }}" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
||||
<input class="form-control" type="date" name="year_to" value="{{ year_to_value }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,12 +83,11 @@
|
||||
</div>
|
||||
<div class="collapse col-12 {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="external" {{ source_value == 'external' ? 'selected' : '' }}>Last.fm</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
<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" />
|
||||
@@ -100,10 +99,10 @@
|
||||
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
||||
<input class="form-control" type="date" name="year_from" value="{{ year_from_value }}" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
||||
<input class="form-control" type="date" name="year_to" value="{{ year_to_value }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,49 +110,659 @@
|
||||
</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">
|
||||
<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 %}
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
{% set image = (album.images[0] ?? album.images[1] ?? null) %}
|
||||
{% if image %}
|
||||
<a href="{{ path('album_show', {id: album.id}) }}">
|
||||
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5>
|
||||
<p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p>
|
||||
<p class="card-text text-secondary">
|
||||
Released {{ album.release_date }} • {{ album.total_tracks }} tracks
|
||||
{% if album.genres is defined and album.genres is not empty %}
|
||||
<br><small>Genre: {{ album.genres|join(', ') }}</small>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
|
||||
<p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
|
||||
<div class="mt-auto">
|
||||
{% if album.external_urls.external is defined and album.external_urls.external %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
|
||||
{% if app.user and (album.source is not defined or album.source != 'user') %}
|
||||
{% if savedIds is defined and (album.id in savedIds) %}
|
||||
{# Saved indicator intentionally omitted to reduce noise #}
|
||||
{% else %}
|
||||
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: album.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ album.id) }}">
|
||||
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
Reference in new issue
Block a user