Files
tonehaus/templates/album/search.html.twig
T
2026-04-02 13:46:07 +01:00

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 %}