what the fuck
This commit is contained in:
1 parent
c1b1c2f372
commit
72a99a5bcd
69 files changed
+7519
-558
No files matched your search
@@ -0,0 +1,83 @@
|
||||
{% set album_id = album.id|default('') %}
|
||||
{% set is_external = album.source is defined and album.source == 'external' %}
|
||||
{% set has_track_count = (album.total_tracks|default(0)) > 0 %}
|
||||
{% set has_release_date = album.release_date is defined and album.release_date %}
|
||||
{% set needs_hydration = is_external and (not has_track_count) %}
|
||||
{% set show_url = path('album_show', {id: album_id}) %}
|
||||
{% set review_href = needs_hydration ? '#' : show_url %}
|
||||
|
||||
<div class="col" data-card-col="1">
|
||||
<div class="card h-100 js-album-card" data-album-id="{{ album_id }}" data-hydrate="{{ needs_hydration ? '1' : '0' }}">
|
||||
{% set image = (album.images[0] ?? album.images[1] ?? null) %}
|
||||
{% if image %}
|
||||
{% set image_url = image.url|default('') %}
|
||||
{% set preview_image_url = image_url %}
|
||||
{% set highres_image_url = '' %}
|
||||
{% if '/i/u/300x300/' in image_url %}
|
||||
{% set preview_image_url = image_url|replace({'/i/u/300x300/': '/i/u/174s/'}) %}
|
||||
{% set highres_image_url = image_url %}
|
||||
{% elseif '/front-500' in image_url %}
|
||||
{% set preview_image_url = image_url|replace({'/front-500': '/front-250'}) %}
|
||||
{% elseif '/front-1200' in image_url %}
|
||||
{% set preview_image_url = image_url|replace({'/front-1200': '/front-250'}) %}
|
||||
{% elseif image_url matches '/\\/front$/' %}
|
||||
{% set preview_image_url = image_url ~ '-250' %}
|
||||
{% endif %}
|
||||
<a href="{{ review_href }}" class="js-image-link {{ needs_hydration ? 'disabled pe-none' : '' }}" {% if needs_hydration %}aria-disabled="true"{% endif %}>
|
||||
<img
|
||||
class="card-img-top js-card-image"
|
||||
src="{{ preview_image_url }}"
|
||||
{% if highres_image_url %}data-highres="{{ highres_image_url }}"{% endif %}
|
||||
alt="{{ album.name }} cover"
|
||||
style="width: 100%; height: 220px; object-fit: cover; background: #f3edf0;"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
{% elseif needs_hydration %}
|
||||
<a href="#" class="js-image-link disabled pe-none" aria-disabled="true">
|
||||
<div class="card-img-top js-image-shell d-flex align-items-center justify-content-center text-secondary" style="width: 100%; height: 220px; object-fit: cover; background: #f3edf0;">
|
||||
Loading cover...
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title"><a href="{{ review_href }}" class="text-decoration-none js-title-link {{ needs_hydration ? 'disabled pe-none' : '' }}" {% if needs_hydration %}aria-disabled="true"{% endif %}>{{ album.name }}</a></h5>
|
||||
<p class="card-text text-secondary js-artist-line">{{ album.artists|map(a => a.name)|join(', ') }}</p>
|
||||
<p class="card-text text-secondary js-release-line">
|
||||
{% if needs_hydration %}
|
||||
Loading album details...
|
||||
{% else %}
|
||||
{% set release_text = album.release_date is defined and album.release_date ? album.release_date : 'Unknown release date' %}
|
||||
Released {{ release_text }} • {{ album.total_tracks }} tracks
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="card-text text-secondary js-genre-line {% if album.genres is not defined or album.genres is empty %}d-none{% endif %}">
|
||||
{% if album.genres is defined and album.genres is not empty %}
|
||||
<small>Genre: {{ album.genres|join(', ') }}</small>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
|
||||
<p class="card-text js-score-line"><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 and not needs_hydration %}
|
||||
<a class="btn btn-outline-success btn-sm js-open-source" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-success btn-sm js-reviews-link {{ needs_hydration ? 'disabled' : '' }}" href="{{ review_href }}" {% if needs_hydration %}aria-disabled="true"{% endif %}>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 js-save-form {{ needs_hydration ? 'd-none' : '' }}" 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 %}
|
||||
{% if needs_hydration %}
|
||||
<p class="small text-secondary mt-2 mb-0 js-hydration-status">Loading details...</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,16 +3,16 @@
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Edit album</h1>
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
{{ form_errors(form) }}
|
||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.artistsCsv) }}</div>
|
||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.genresCsv) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}{{ form_errors(form.releaseDate) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}{{ form_errors(form.totalTracks) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}{{ form_errors(form.coverUpload) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}{{ form_errors(form.externalUrl) }}</div>
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
<a class="btn btn-link" href="{{ path('album_show', {id: albumId}) }}">Cancel</a>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}{{ isEdit ? 'Edit Album' : 'Create Album' }}{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">{{ isEdit ? 'Edit album' : 'Create album' }}</h1>
|
||||
<style>
|
||||
.album-editor-cover {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
border: 2px dashed #d29ab2;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #faf6f8;
|
||||
color: #8a4f69;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.album-editor-cover:hover {
|
||||
border-color: #b55b80;
|
||||
background: #f6eef2;
|
||||
}
|
||||
|
||||
.album-editor-cover__preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-col-disc,
|
||||
.track-col-number {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.track-col-duration {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.track-meta-input {
|
||||
min-height: 2.8rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track-title-input,
|
||||
.track-duration-input {
|
||||
min-height: 2.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-4', novalidate: 'novalidate'}}) }}
|
||||
{{ form_errors(form) }}
|
||||
|
||||
<div class="row g-4 mb-2">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body vstack gap-3">
|
||||
<div>
|
||||
{{ form_widget(form.coverUpload, {attr: {class: 'd-none', accept: 'image/*'}}) }}
|
||||
{% set has_existing_cover = existingCoverUrl is defined and existingCoverUrl %}
|
||||
<label for="{{ form.coverUpload.vars.id }}" class="album-editor-cover" id="cover-upload-trigger">
|
||||
<img
|
||||
id="cover-upload-preview"
|
||||
class="album-editor-cover__preview{{ has_existing_cover ? '' : ' d-none' }}"
|
||||
{% if has_existing_cover %}src="{{ existingCoverUrl }}"{% endif %}
|
||||
alt="Album cover preview"
|
||||
>
|
||||
<span id="cover-upload-placeholder" class="{{ has_existing_cover ? 'd-none' : '' }}">Upload Image</span>
|
||||
</label>
|
||||
{{ form_errors(form.coverUpload) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.name) }}
|
||||
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.artistsCsv) }}
|
||||
{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.artistsCsv) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.releaseDate) }}
|
||||
{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.releaseDate) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.genresCsv) }}
|
||||
{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.genresCsv) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.externalUrl) }}
|
||||
{{ form_widget(form.externalUrl, {attr: {class: 'form-control', placeholder: 'https://...'}}) }}
|
||||
{{ form_errors(form.externalUrl) }}
|
||||
</div>
|
||||
<div class="d-none">
|
||||
{{ form_widget(form.totalTracks) }}
|
||||
{{ form_errors(form.totalTracks) }}
|
||||
</div>
|
||||
{% if isEdit and albumOwner %}
|
||||
<small class="text-secondary">
|
||||
Owner: {{ albumOwner.displayName ?? albumOwner.userIdentifier }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 mb-0">Tracklist</h2>
|
||||
<span class="text-secondary"><span id="track-count">{{ trackRows|filter(t => t.title is not empty)|length }}</span> tracks</span>
|
||||
</div>
|
||||
<div class="mui-table-wrapper w-100">
|
||||
<table class="mui-table mui-table--compact mui-table--striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Disc</th>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col" class="text-end">Duration</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="track-editor-body">
|
||||
{% for track in trackRows %}
|
||||
<tr>
|
||||
<td class="mui-table__number track-col-disc">
|
||||
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_disc[]" value="{{ track.disc }}">
|
||||
</td>
|
||||
<td class="mui-table__number track-col-number">
|
||||
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_number[]" value="{{ track.track }}">
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control track-title-input" type="text" name="track_title[]" value="{{ track.title }}" maxlength="512" placeholder="Track title">
|
||||
</td>
|
||||
<td class="text-end track-col-duration">
|
||||
<input class="form-control track-duration-input text-end" type="text" inputmode="numeric" name="track_duration[]" value="{{ track.duration }}" placeholder="m:ss">
|
||||
</td>
|
||||
<td class="text-end" style="width:1%">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm track-remove-btn" title="Remove track">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="button" id="add-track-row" class="btn btn-outline-secondary btn-sm">Add track</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" type="submit">{{ isEdit ? 'Save changes' : 'Create album' }}</button>
|
||||
<a class="btn btn-link" href="{{ isEdit and albumId ? path('album_show', {id: albumId}) : path('album_search') }}">Cancel</a>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
|
||||
<template id="track-row-template">
|
||||
<tr>
|
||||
<td class="mui-table__number track-col-disc">
|
||||
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_disc[]" value="1">
|
||||
</td>
|
||||
<td class="mui-table__number track-col-number">
|
||||
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_number[]" value="1">
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control track-title-input" type="text" name="track_title[]" value="" maxlength="512" placeholder="Track title">
|
||||
</td>
|
||||
<td class="text-end track-col-duration">
|
||||
<input class="form-control track-duration-input text-end" type="text" inputmode="numeric" name="track_duration[]" value="" placeholder="m:ss">
|
||||
</td>
|
||||
<td class="text-end" style="width:1%">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm track-remove-btn" title="Remove track">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const trackBody = document.getElementById('track-editor-body');
|
||||
const addBtn = document.getElementById('add-track-row');
|
||||
const rowTemplate = document.getElementById('track-row-template');
|
||||
const countNode = document.getElementById('track-count');
|
||||
const totalTracksInput = document.getElementById('{{ form.totalTracks.vars.id }}');
|
||||
const coverInput = document.getElementById('{{ form.coverUpload.vars.id }}');
|
||||
const coverPreview = document.getElementById('cover-upload-preview');
|
||||
const coverPlaceholder = document.getElementById('cover-upload-placeholder');
|
||||
if (!trackBody || !addBtn || !rowTemplate || !countNode || !totalTracksInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recalcTrackCount = () => {
|
||||
const titles = Array.from(trackBody.querySelectorAll('.track-title-input'));
|
||||
const count = titles.filter((input) => input.value.trim() !== '').length;
|
||||
countNode.textContent = String(count);
|
||||
totalTracksInput.value = String(count);
|
||||
};
|
||||
|
||||
const bindRow = (row) => {
|
||||
const removeBtn = row.querySelector('.track-remove-btn');
|
||||
const titleInput = row.querySelector('.track-title-input');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
row.remove();
|
||||
recalcTrackCount();
|
||||
});
|
||||
}
|
||||
if (titleInput) {
|
||||
titleInput.addEventListener('input', recalcTrackCount);
|
||||
}
|
||||
};
|
||||
|
||||
Array.from(trackBody.querySelectorAll('tr')).forEach((row) => bindRow(row));
|
||||
|
||||
addBtn.addEventListener('click', () => {
|
||||
const fragment = rowTemplate.content.cloneNode(true);
|
||||
const row = fragment.querySelector('tr');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const currentRows = trackBody.querySelectorAll('tr').length;
|
||||
const trackNumberInput = row.querySelector('input[name="track_number[]"]');
|
||||
if (trackNumberInput) {
|
||||
trackNumberInput.value = String(currentRows + 1);
|
||||
}
|
||||
bindRow(row);
|
||||
trackBody.appendChild(row);
|
||||
});
|
||||
|
||||
if (coverInput && coverPreview && coverPlaceholder) {
|
||||
coverInput.addEventListener('change', () => {
|
||||
const file = coverInput.files && coverInput.files.length > 0 ? coverInput.files[0] : null;
|
||||
if (!file) {
|
||||
coverPreview.src = '';
|
||||
coverPreview.classList.add('d-none');
|
||||
coverPlaceholder.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
coverPreview.src = objectUrl;
|
||||
coverPreview.classList.remove('d-none');
|
||||
coverPlaceholder.classList.add('d-none');
|
||||
coverPreview.onload = () => URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
}
|
||||
|
||||
recalcTrackCount();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,15 +3,15 @@
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Create album</h1>
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
{{ form_errors(form) }}
|
||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.artistsCsv) }}</div>
|
||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.genresCsv) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}{{ form_errors(form.releaseDate) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}{{ form_errors(form.totalTracks) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}{{ form_errors(form.coverUpload) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}{{ form_errors(form.externalUrl) }}</div>
|
||||
<button class="btn btn-success" type="submit">Create</button>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -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