what the fuck

This commit is contained in:
boris committed 2026-04-02 13:46:07 +01:00
1 parent c1b1c2f372
commit 72a99a5bcd
69 files changed
+7519 -558

No files matched your search

+17 -10
View File
@@ -71,18 +71,27 @@
<h2 class="h6 mb-3">Your albums</h2>
<div class="vstack gap-2">
{% for a in userAlbums %}
{% set publicId = a.externalId %}
<div class="d-flex justify-content-between align-items-start">
<div class="me-2">
<div><a href="{{ path('album_show', {id: a.localId}) }}" class="text-decoration-none">{{ a.name }}</a></div>
<div>
{% if publicId %}
<a href="{{ path('album_show', {id: publicId}) }}" class="text-decoration-none">{{ a.name }}</a>
{% else %}
{{ a.name }}
{% endif %}
</div>
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %}</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{{ path('album_edit', {id: a.localId}) }}">Edit</a>
<form method="post" action="{{ path('album_delete', {id: a.localId}) }}" onsubmit="return confirm('Delete this album?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ a.localId) }}" />
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
</form>
</div>
{% if publicId %}
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{{ path('album_edit', {id: publicId}) }}">Edit</a>
<form method="post" action="{{ path('album_delete', {id: publicId}) }}" onsubmit="return confirm('Delete this album?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ publicId) }}" />
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
</form>
</div>
{% endif %}
</div>
{% else %}
<div class="text-secondary">You haven't created any albums yet.</div>
@@ -93,5 +102,3 @@
</div>
</div>
{% endblock %}
+74 -1
View File
@@ -4,13 +4,58 @@
<h1 class="h4 mb-3">Site Settings</h1>
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
{% for msg in app.flashes('warning') %}<div class="alert alert-warning">{{ msg }}</div>{% endfor %}
{% for msg in app.flashes('danger') %}<div class="alert alert-danger">{{ msg }}</div>{% endfor %}
<div class="card mb-4">
<div class="card-body">
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
{{ form_start(form, {attr: {id: 'site-settings-form', class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>
{{ form_label(form.EXTERNAL_ALBUM_PROVIDER) }}
{{ form_widget(form.EXTERNAL_ALBUM_PROVIDER, {attr: {class: 'form-select', 'data-initial-provider': storedProvider}}) }}
<div class="form-text">Switching provider clears album/review catalog data to prevent mixed-source duplicates.</div>
<div class="form-text">Stored provider: <strong>{{ providerLabelStored }}</strong> • Active provider: <strong>{{ providerLabelActive }}</strong> • Effective now: <strong>{{ providerLabelEffective }}</strong></div>
{% if envOverrideProvider %}
<div class="form-text text-warning">Environment override <code>EXTERNAL_ALBUM_PROVIDER={{ envOverrideProvider }}</code> is active and takes priority over saved settings.</div>
{% endif %}
</div>
<div>
{{ form_label(form.LASTFM_API_KEY) }}
{{ form_widget(form.LASTFM_API_KEY, {attr: {class: 'form-control'}}) }}
<div class="form-text">Environment override: <code>LASTFM_API_KEY</code>.</div>
</div>
<hr class="my-1">
<div>
{{ form_label(form.MUSICBRAINZ_CLIENT_ID) }}
{{ form_widget(form.MUSICBRAINZ_CLIENT_ID, {attr: {class: 'form-control'}}) }}
<div class="form-text">Optional. Not required for album search. Create OAuth app credentials at <a href="https://musicbrainz.org/account/applications" target="_blank" rel="noopener">MusicBrainz account applications</a>. Environment override: <code>MUSICBRAINZ_CLIENT_ID</code>.</div>
</div>
<div>
{{ form_label(form.MUSICBRAINZ_CLIENT_SECRET) }}
{{ form_widget(form.MUSICBRAINZ_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
<div class="form-text">Optional. Not required for album search. Pairs with client ID from <a href="https://musicbrainz.org/account/applications" target="_blank" rel="noopener">MusicBrainz account applications</a>. Environment override: <code>MUSICBRAINZ_CLIENT_SECRET</code>.</div>
</div>
<div>
{{ form_label(form.MUSICBRAINZ_ACCESS_TOKEN) }}
{{ form_widget(form.MUSICBRAINZ_ACCESS_TOKEN, {attr: {class: 'form-control'}}) }}
<div class="form-text">Optional. Leave empty for normal search.</div>
<div class="form-text">If you need authenticated MusicBrainz features, create an OAuth app under <a href="https://musicbrainz.org/account/applications" target="_blank" rel="noopener">MusicBrainz account applications</a>, authorize it, then paste the returned access token here. Environment override: <code>MUSICBRAINZ_ACCESS_TOKEN</code>.</div>
</div>
<hr class="my-1">
<div>
{{ form_label(form.TIDAL_CLIENT_ID) }}
{{ form_widget(form.TIDAL_CLIENT_ID, {attr: {class: 'form-control'}}) }}
<div class="form-text">Required for TIDAL provider. Create credentials at <a href="https://developer.tidal.com/" target="_blank" rel="noopener">developer.tidal.com</a>. Environment override: <code>TIDAL_CLIENT_ID</code>.</div>
</div>
<div>
{{ form_label(form.TIDAL_CLIENT_SECRET) }}
{{ form_widget(form.TIDAL_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
<div class="form-text">Required for TIDAL provider. Environment override: <code>TIDAL_CLIENT_SECRET</code>.</div>
</div>
<div>
{{ form_label(form.TIDAL_COUNTRY_CODE) }}
{{ form_widget(form.TIDAL_COUNTRY_CODE, {attr: {class: 'form-control', placeholder: 'US'}}) }}
<div class="form-text">Optional ISO 3166-1 alpha-2 country code used for catalog availability (defaults to <code>US</code>). Environment override: <code>TIDAL_COUNTRY_CODE</code>.</div>
</div>
<div class="form-check form-switch">
{% set registrationToggleAttrs = {class: 'form-check-input'} %}
@@ -88,3 +133,31 @@
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
(function () {
const form = document.getElementById('site-settings-form');
const providerField = document.getElementById('{{ form.EXTERNAL_ALBUM_PROVIDER.vars.id }}');
if (!form || !providerField) {
return;
}
const initialProvider = providerField.dataset.initialProvider || providerField.value;
const envOverrideActive = {{ envOverrideProvider ? 'true' : 'false' }};
form.addEventListener('submit', function (event) {
if (providerField.value === initialProvider) {
return;
}
const message = envOverrideActive
? 'EXTERNAL_ALBUM_PROVIDER override is active. This change saves your preferred provider, but reset only occurs when the effective provider actually changes. Continue?'
: 'Changing the external provider will reset all albums, tracks, and reviews to avoid mixed-source duplicates. Continue?';
const ok = window.confirm(message);
if (!ok) {
event.preventDefault();
}
});
})();
</script>
{% endblock %}
+4 -5
View File
@@ -6,7 +6,7 @@
<form method="post" action="{{ path('admin_dashboard_refresh_external') }}" class="ms-auto">
<input type="hidden" name="_token" value="{{ csrf_token('dashboard_refresh_external') }}">
<button class="btn btn-outline-success" type="submit">
Refresh Last.fm metadata
Refresh {{ externalProviderLabel }} metadata
</button>
</form>
</div>
@@ -68,7 +68,7 @@
<h2 class="h6 mb-3">Latest albums (50)</h2>
<div class="vstack gap-2">
{% for a in recentAlbums %}
{% set publicId = a.source == 'user' ? a.localId : a.externalId %}
{% set publicId = a.externalId %}
<div class="d-flex justify-content-between align-items-start">
<div class="me-2">
<div>
@@ -77,11 +77,11 @@
{% else %}
<span>{{ a.name }}</span>
{% endif %}
{% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">Last.fm</span>{% endif %}
{% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">{{ externalProviderLabel }}</span>{% endif %}
</div>
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}</div>
</div>
{% if publicId %}
{% if publicId and a.source == 'user' %}
<form method="post" action="{{ path('album_delete', {id: publicId}) }}" onsubmit="return confirm('Delete this album?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ publicId) }}" />
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
@@ -97,4 +97,3 @@
</div>
</div>
{% endblock %}
+83
View File
@@ -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>
+7 -7
View File
@@ -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 %}
+261
View File
@@ -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">&times;</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">&times;</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 %}
+7 -7
View File
@@ -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 %}
+664 -55
View File
@@ -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 %}
-31
View File
@@ -1,31 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tonehaus API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.ui = SwaggerUIBundle({
url: '{{ specUrl }}',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: 'StandaloneLayout'
});
</script>
</body>
</html>
-1
View File
@@ -46,4 +46,3 @@
</body>
</html>
+3 -2
View File
@@ -20,7 +20,9 @@
</div>
<p class="text-secondary">
{{ review.album.name }} — {{ review.album.artists|join(', ') }}
<a class="ms-1" href="{{ path('album_show', {id: review.album.externalId ?? review.album.localId}) }}">View album</a>
{% if review.album.externalId %}
<a class="ms-1" href="{{ path('album_show', {id: review.album.externalId}) }}">View album</a>
{% endif %}
</p>
<article class="mb-3">
<p>{{ review.content|nl2br }}</p>
@@ -37,4 +39,3 @@
{% endif %}
{% endblock %}