This commit is contained in:
@@ -13,9 +13,12 @@
|
||||
</table>
|
||||
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||
{% if job.enabled() %}
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
|
||||
@@ -23,14 +26,47 @@
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>{{ t.jobs_cron }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
|
||||
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
|
||||
{% if job.name_str() == "metadata_backfill" %}
|
||||
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
|
||||
<form method="post" action="/admin/jobs/metadata_backfill/run-options" style="margin:0 0 1.5rem; padding:1rem; background:#fff; border:1px solid #e0e0e0; border-radius:6px;">
|
||||
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
|
||||
<legend style="font-weight:600; margin-bottom:.5rem;">{{ t.jobs_metadata_backfill_fields }}</legend>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_bitrate" checked> audio_bitrate
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_sample_rate" checked> audio_sample_rate
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_bit_depth" checked> audio_bit_depth
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="duration_seconds" checked> duration_seconds
|
||||
</label>
|
||||
</fieldset>
|
||||
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
<input type="radio" name="mode" value="fill_missing" checked> {{ t.jobs_metadata_backfill_fill_missing }}
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
<input type="radio" name="mode" value="overwrite"> {{ t.jobs_metadata_backfill_overwrite }}
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" style="padding:.45rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_metadata_backfill_run }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<h2>{{ t.jobs_cron }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
|
||||
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ t.jobs_run_history }}</h2>
|
||||
{% if runs.is_empty() %}
|
||||
|
||||
@@ -23,9 +23,14 @@
|
||||
<td>{{ job.last_run_at_str() }}</td>
|
||||
<td>{{ job.next_run_at_str() }}</td>
|
||||
<td style="display:flex;gap:.3rem;">
|
||||
{% if job.name_str() == "metadata_backfill" %}
|
||||
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||
{% if job.enabled() %}
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
|
||||
@@ -33,6 +38,7 @@
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
|
||||
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
|
||||
.admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; }
|
||||
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
|
||||
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
||||
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
{% block body %}
|
||||
<nav class="sidebar">
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
|
||||
<a href="/admin/">{{ t.nav_dashboard }}</a>
|
||||
<a href="/admin/artists">{{ t.nav_artists }}</a>
|
||||
<a href="/admin/releases">{{ t.nav_releases }}</a>
|
||||
|
||||
+215
-12
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
<h1>{{ t.reviews_heading }}</h1>
|
||||
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
|
||||
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
|
||||
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
|
||||
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
|
||||
@@ -13,7 +13,7 @@
|
||||
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
|
||||
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
|
||||
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
|
||||
{% if !reviews.is_empty() %}
|
||||
{% if !rows.is_empty() %}
|
||||
<span style="flex:1;"></span>
|
||||
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
|
||||
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
|
||||
@@ -21,15 +21,27 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if reviews.is_empty() %}
|
||||
{% if rows.is_empty() %}
|
||||
<p>{{ t.reviews_empty }}</p>
|
||||
{% else %}
|
||||
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
|
||||
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
|
||||
<input type="hidden" name="status_filter" value="{{ status_filter }}">
|
||||
<div class="review-bulk-toolbar">
|
||||
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
|
||||
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
|
||||
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
|
||||
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
|
||||
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
|
||||
</div>
|
||||
<table>
|
||||
<tr>
|
||||
<th class="review-select-cell"></th>
|
||||
<th>ID</th>
|
||||
<th>{{ t.reviews_status }}</th>
|
||||
<th>{{ t.reviews_type }}</th>
|
||||
<th>{{ t.reviews_input_path }}</th>
|
||||
<th>{{ t.reviews_tags }}</th>
|
||||
<th>{{ t.reviews_confidence }}</th>
|
||||
<th>{{ t.reviews_model }}</th>
|
||||
<th>{{ t.reviews_llm_duration }}</th>
|
||||
@@ -37,14 +49,22 @@
|
||||
<th>{{ t.reviews_created }}</th>
|
||||
<th>{{ t.jobs_actions }}</th>
|
||||
</tr>
|
||||
{% for review in reviews %}
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
|
||||
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
|
||||
<td>{{ review.review_type_str() }}</td>
|
||||
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ review.input_path_str() }}">{{ review.input_path_str() }}</td>
|
||||
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
|
||||
{% match stats_map.get(&review.id_val()) %}
|
||||
<td class="review-select-cell">
|
||||
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
|
||||
</td>
|
||||
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
|
||||
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
|
||||
<td>{{ row.review.review_type_str() }}</td>
|
||||
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
|
||||
<td class="review-tag-cell">
|
||||
{% for tag in row.media_tags %}
|
||||
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
|
||||
{% match stats_map.get(&row.review.id_val()) %}
|
||||
{% when Some with (s) %}
|
||||
<td>{{ s.model_name }}</td>
|
||||
<td>{{ s.duration_display() }}</td>
|
||||
@@ -54,20 +74,203 @@
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
{% endmatch %}
|
||||
<td>{{ review.created_at_str() }}</td>
|
||||
<td>{{ row.review.created_at_str() }}</td>
|
||||
<td>
|
||||
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
|
||||
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.review-bulk-toolbar {
|
||||
margin-bottom: .75rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.review-toolbar-button,
|
||||
.review-danger-button,
|
||||
.review-primary-button {
|
||||
padding: .35rem .7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #fff;
|
||||
color: #212529;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-danger-button {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
.review-primary-button {
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
.review-danger-button:disabled,
|
||||
.review-primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .45;
|
||||
}
|
||||
.review-selection-summary {
|
||||
min-height: 1.7rem;
|
||||
padding: .35rem .6rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-size: .9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.review-select-cell {
|
||||
width: 2.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.review-select {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.review-input-path {
|
||||
max-width: 34rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-tag-cell {
|
||||
max-width: 18rem;
|
||||
}
|
||||
.review-tag {
|
||||
display: inline-block;
|
||||
margin: .1rem .15rem .1rem 0;
|
||||
padding: .12rem .35rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-size: .8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
|
||||
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
|
||||
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
|
||||
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
|
||||
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||
.badge-pending { background: #fff3cd; color: #856404; }
|
||||
.badge-queued { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-processing { background: #cce5ff; color: #004085; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.getElementById("reviews-bulk-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
|
||||
const selectedIdsInput = document.getElementById("selected-review-ids");
|
||||
const summary = document.getElementById("review-selection-summary");
|
||||
const selectShownButton = document.getElementById("select-shown-reviews");
|
||||
const clearSelectionButton = document.getElementById("clear-review-selection");
|
||||
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
|
||||
const selected = new Set();
|
||||
const statusCounts = new Map();
|
||||
|
||||
const labels = {
|
||||
pending: "{{ t.reviews_filter_pending }}",
|
||||
approved: "{{ t.reviews_filter_approved }}",
|
||||
rejected: "{{ t.reviews_filter_rejected }}",
|
||||
queued: "{{ t.reviews_filter_queued }}",
|
||||
processing: "{{ t.reviews_filter_processing }}",
|
||||
auto_approved: "{{ t.reviews_filter_auto_approved }}",
|
||||
failed: "{{ t.reviews_filter_failed }}"
|
||||
};
|
||||
|
||||
function setStatusCount(status, delta) {
|
||||
const next = (statusCounts.get(status) || 0) + delta;
|
||||
if (next > 0) {
|
||||
statusCounts.set(status, next);
|
||||
} else {
|
||||
statusCounts.delete(status);
|
||||
}
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
selectedIdsInput.value = Array.from(selected).join(",");
|
||||
const total = selected.size;
|
||||
for (const button of submitButtons) {
|
||||
button.disabled = total === 0;
|
||||
}
|
||||
if (total === 0) {
|
||||
summary.textContent = "{{ t.reviews_selected_none }}";
|
||||
return;
|
||||
}
|
||||
const parts = Array.from(statusCounts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([status, count]) => `${labels[status] || status}: ${count}`);
|
||||
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function setChecked(checkbox, checked) {
|
||||
const id = checkbox.value;
|
||||
const isSelected = selected.has(id);
|
||||
checkbox.checked = checked;
|
||||
if (isSelected === checked) {
|
||||
return;
|
||||
}
|
||||
const status = checkbox.dataset.status || "unknown";
|
||||
if (checked) {
|
||||
selected.add(id);
|
||||
setStatusCount(status, 1);
|
||||
} else {
|
||||
selected.delete(id);
|
||||
setStatusCount(status, -1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const checkbox of checkboxes) {
|
||||
checkbox.addEventListener("change", () => {
|
||||
setChecked(checkbox, checkbox.checked);
|
||||
syncControls();
|
||||
});
|
||||
}
|
||||
|
||||
selectShownButton.addEventListener("click", () => {
|
||||
for (const checkbox of checkboxes) {
|
||||
setChecked(checkbox, true);
|
||||
}
|
||||
syncControls();
|
||||
});
|
||||
|
||||
clearSelectionButton.addEventListener("click", () => {
|
||||
for (const checkbox of checkboxes) {
|
||||
setChecked(checkbox, false);
|
||||
}
|
||||
syncControls();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
syncControls();
|
||||
if (selected.size === 0) {
|
||||
event.preventDefault();
|
||||
alert("{{ t.reviews_none_selected_confirm }}");
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.submitter ? event.submitter.value : "";
|
||||
const message = action === "requeue"
|
||||
? "{{ t.reviews_requeue_selected_confirm }}"
|
||||
: "{{ t.reviews_delete_selected_confirm }}";
|
||||
if (!confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
syncControls();
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
||||
+85
-26
@@ -273,6 +273,22 @@ body {
|
||||
.artist-header .artist-img svg { width: 80px; height: 80px; color: var(--text-subdued); }
|
||||
|
||||
.artist-header .artist-name { font-size: 48px; font-weight: 900; line-height: 1.1; }
|
||||
.artist-stats {
|
||||
color: var(--text-subdued);
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.artist-release-group { margin-top: 28px; }
|
||||
.artist-release-group:first-of-type { margin-top: 0; }
|
||||
.artist-release-group-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Release detail header */
|
||||
.release-header {
|
||||
@@ -1355,35 +1371,45 @@ body {
|
||||
</div>
|
||||
<div>
|
||||
<div class="artist-header .artist-name" x-text="$store.library.currentArtist.name" style="font-size:48px;font-weight:900;line-height:1.1"></div>
|
||||
<div style="color:var(--text-subdued);margin-top:8px" x-text="$store.library.currentArtist.releases.length + ' releases'"></div>
|
||||
<div class="artist-stats">
|
||||
<span x-text="$store.library.currentArtist.releases.length + ' releases'"></span>
|
||||
<span>•</span>
|
||||
<span x-text="$store.library.currentArtist.total_track_count + ' tracks'"></span>
|
||||
<span>•</span>
|
||||
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="section-title" style="font-size:20px">Releases</h2>
|
||||
<div class="card-grid">
|
||||
<template x-for="release in $store.library.currentArtist.releases" :key="release.id">
|
||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||
</template>
|
||||
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title" x-text="release.title"></div>
|
||||
<div class="card-subtitle">
|
||||
<span x-text="release.year || ''"></span>
|
||||
<span x-text="release.release_type"></span>
|
||||
</div>
|
||||
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title" x-text="group.label"></h2>
|
||||
<div class="card-grid">
|
||||
<template x-for="release in group.releases" :key="release.id">
|
||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||
</template>
|
||||
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title" x-text="release.title"></div>
|
||||
<div class="card-subtitle">
|
||||
<span x-text="release.year || ''"></span>
|
||||
<span x-text="release.track_count + ' tracks'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2179,6 +2205,39 @@ document.addEventListener('alpine:init', () => {
|
||||
} catch {}
|
||||
},
|
||||
|
||||
artistReleaseGroups() {
|
||||
const releases = this.currentArtist?.releases || [];
|
||||
const order = ['album', 'ep', 'single', 'compilation', 'mixtape', 'live', 'soundtrack'];
|
||||
const labels = {
|
||||
album: 'Albums',
|
||||
ep: 'EPs',
|
||||
single: 'Singles',
|
||||
compilation: 'Compilations',
|
||||
mixtape: 'Mixtapes',
|
||||
live: 'Live releases',
|
||||
soundtrack: 'Soundtracks',
|
||||
};
|
||||
const groups = new Map();
|
||||
for (const release of releases) {
|
||||
const type = (release.release_type || 'other').toLowerCase();
|
||||
if (!groups.has(type)) {
|
||||
groups.set(type, []);
|
||||
}
|
||||
groups.get(type).push(release);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => {
|
||||
const ai = order.includes(a) ? order.indexOf(a) : order.length;
|
||||
const bi = order.includes(b) ? order.indexOf(b) : order.length;
|
||||
return ai === bi ? a.localeCompare(b) : ai - bi;
|
||||
})
|
||||
.map(([type, groupReleases]) => ({
|
||||
type,
|
||||
label: labels[type] || type,
|
||||
releases: groupReleases,
|
||||
}));
|
||||
},
|
||||
|
||||
async openRelease(id) {
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
|
||||
Reference in New Issue
Block a user