Furumi init

This commit is contained in:
2026-05-23 13:08:09 +03:00
parent b8afaa1864
commit 8912c51165
42 changed files with 14279 additions and 54 deletions
+133
View File
@@ -0,0 +1,133 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/artists/{{ form_artist_id }}/edit{% else %}/admin/artists/new{% endif %}">
<table>
<tr>
<td><label for="name">{{ t.artists_name }}</label></td>
<td><input name="name" id="name" value="{{ form_name }}" required style="width:100%"></td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% if is_edit %}
<hr style="margin: 2rem 0;">
<h2>{{ t.artists_image }}</h2>
<div id="artist-image-section" style="margin-top: 1rem;">
<!-- Current image -->
<div style="margin-bottom: 1.5rem;">
{% match current_image_url %}
{% when Some with (url) %}
<img id="current-image" src="{{ url }}" alt="" style="max-width: 200px; max-height: 200px; border-radius: 6px; border: 1px solid #ddd;">
<br>
<button type="button" onclick="removeImage()" style="margin-top: .5rem; padding: .3rem 1rem; cursor: pointer;">{{ t.artists_remove_image }}</button>
{% when None %}
<p style="color: #888;">{{ t.artists_no_image }}</p>
{% endmatch %}
</div>
<!-- Upload custom image -->
<div style="margin-bottom: 1.5rem;">
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_upload_image }}</h3>
<input type="file" id="image-upload" accept="image/*" style="margin-bottom: .5rem;">
<br>
<button type="button" id="upload-btn" onclick="uploadImage()" style="padding: .3rem 1rem; cursor: pointer;" disabled>{{ t.artists_upload }}</button>
</div>
<!-- Pick from album covers -->
<div>
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_pick_cover }}</h3>
<div id="covers-grid" style="display: flex; flex-wrap: wrap; gap: .75rem;">
<p style="color: #888;" id="covers-loading">...</p>
</div>
</div>
</div>
<script>
const artistId = {{ form_artist_id }};
// Enable upload button when file selected
document.getElementById('image-upload').addEventListener('change', function() {
document.getElementById('upload-btn').disabled = !this.files.length;
});
// Upload custom image
function uploadImage() {
const fileInput = document.getElementById('image-upload');
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function() {
const base64 = reader.result.split(',')[1];
fetch('/admin/artists/' + artistId + '/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: base64,
filename: file.name,
mime_type: file.type || 'image/jpeg'
})
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
};
reader.readAsDataURL(file);
}
// Set image from album cover
function setImage(mediaFileId) {
fetch('/admin/artists/' + artistId + '/set-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_file_id: mediaFileId })
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
}
// Remove image
function removeImage() {
fetch('/admin/artists/' + artistId + '/set-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_file_id: null })
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
}
// Load available covers
fetch('/admin/artists/' + artistId + '/available-covers')
.then(function(r) { return r.json(); })
.then(function(covers) {
const grid = document.getElementById('covers-grid');
grid.innerHTML = '';
if (!covers.length) {
grid.innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
return;
}
covers.forEach(function(c) {
const div = document.createElement('div');
div.style.cssText = 'cursor:pointer; text-align:center;';
div.title = c.release_title;
div.innerHTML = '<img src="' + c.cover_url + '" alt="" style="width:100px; height:100px; object-fit:cover; border-radius:4px; border:2px solid #ddd;">'
+ '<br><small style="font-size:.75rem; color:#666;">' + c.release_title.substring(0, 20) + '</small>';
div.onclick = function() { setImage(c.media_file_id); };
grid.appendChild(div);
});
})
.catch(function() {
document.getElementById('covers-grid').innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
});
</script>
{% endif %}
{% endblock content %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_artists }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.artists_heading }}</h1>
<p style="margin-bottom: 1rem;">
<a href="/admin/artists/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.artists_add }}</a>
</p>
{% if rows.is_empty() %}
<p>{{ t.artists_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.artists_name }}</th>
<th>{{ t.artists_releases }}</th>
<th>{{ t.artists_tracks }}</th>
<th>{{ t.artists_hidden }}</th>
<th>{{ t.artists_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.artist.id_val() }}</td>
<td>{{ row.artist.name_str() }}</td>
<td>
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ row.release_count }}</a>
</td>
<td>{{ row.track_count }}</td>
<td>{{ row.artist.is_hidden() }}</td>
<td>
<a href="/admin/artists/{{ row.artist.id_val() }}/edit">{{ t.artists_edit }}</a>
&nbsp;|&nbsp;
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ t.artists_view_releases }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/artists/{{ row.artist.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.artists_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.artists_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock content %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ job.name_str() }}{% endblock admin_title %}
{% block content %}
<h1>{{ job.name_str() }}</h1>
<table>
<tr><th>{{ t.jobs_description }}</th><td>{{ job.description_str() }}</td></tr>
<tr><th>{{ t.jobs_cron }}</th><td><code>{{ job.cron_expression_str() }}</code></td></tr>
<tr><th>{{ t.jobs_enabled }}</th><td>{% if job.enabled() %}&#9989;{% else %}&#10060;{% endif %}</td></tr>
<tr><th>{{ t.jobs_last_run }}</th><td>{{ job.last_run_at_str() }}</td></tr>
<tr><th>{{ t.jobs_next_run }}</th><td>{{ job.next_run_at_str() }}</td></tr>
</table>
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
<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>
<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>
{% else %}
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
</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>
</form>
<h2>{{ t.jobs_run_history }}</h2>
{% if runs.is_empty() %}
<p>No runs yet.</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.jobs_run_status }}</th>
<th>{{ t.jobs_run_started }}</th>
<th>{{ t.jobs_run_duration }}</th>
<th>{{ t.jobs_run_trigger }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for run in runs %}
<tr>
<td>{{ run.id_val() }}</td>
<td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td>
<td>{{ run.started_at_str() }}</td>
<td>{{ run.duration_display() }}</td>
<td>{{ run.trigger_str() }}</td>
<td><a href="/admin/jobs/{{ job.name_str() }}/runs/{{ run.id_val() }}">{{ t.reviews_view }}</a></td>
</tr>
{% endfor %}
</table>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/jobs">&larr; {{ t.jobs_back_to_list }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-processing { background: #d1ecf1; color: #0c5460; }
</style>
{% endblock content %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.jobs_run_detail }} #{{ run.id_val() }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.jobs_run_detail }} #{{ run.id_val() }}</h1>
<table>
<tr><th>{{ t.jobs_run_status }}</th><td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td></tr>
<tr><th>{{ t.jobs_run_trigger }}</th><td>{{ run.trigger_str() }}</td></tr>
<tr><th>{{ t.jobs_run_started }}</th><td>{{ run.started_at_str() }}</td></tr>
<tr><th>{{ t.jobs_run_duration }}</th><td>{{ run.duration_display() }}</td></tr>
</table>
{% if !run.error_message_str().is_empty() %}
<h2>{{ t.jobs_run_error }}</h2>
<pre style="background:#f8d7da; color:#721c24; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ run.error_message_str() }}</pre>
{% endif %}
{% if !run.log_output_str().is_empty() %}
<h2>{{ t.jobs_run_log }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem; max-height:40em; overflow-y:auto;">{{ run.log_output_str() }}</pre>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/jobs/{{ job_name }}">&larr; {{ t.jobs_back_to_job }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-processing { background: #d1ecf1; color: #0c5460; }
</style>
{% endblock content %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_jobs }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.jobs_heading }}</h1>
<table>
<tr>
<th>{{ t.jobs_name }}</th>
<th>{{ t.jobs_description }}</th>
<th>{{ t.jobs_cron }}</th>
<th>{{ t.jobs_enabled }}</th>
<th>{{ t.jobs_last_run }}</th>
<th>{{ t.jobs_next_run }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for job in jobs %}
<tr>
<td><a href="/admin/jobs/{{ job.name_str() }}">{{ job.name_str() }}</a></td>
<td>{{ job.description_str() }}</td>
<td><code>{{ job.cron_expression_str() }}</code></td>
<td>{% if job.enabled() %}&#9989;{% else %}&#10060;{% endif %}</td>
<td>{{ job.last_run_at_str() }}</td>
<td>{{ job.next_run_at_str() }}</td>
<td style="display:flex;gap:.3rem;">
<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>
<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>
{% else %}
<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>
</td>
</tr>
{% endfor %}
</table>
{% endblock content %}
+6
View File
@@ -3,6 +3,7 @@
{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
@@ -37,6 +38,11 @@
<nav class="sidebar">
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
<a href="/admin/">{{ t.nav_dashboard }}</a>
<a href="/admin/artists">{{ t.nav_artists }}</a>
<a href="/admin/releases">{{ t.nav_releases }}</a>
<a href="/admin/media-files">{{ t.nav_media_files }}</a>
<a href="/admin/jobs">{{ t.nav_jobs }}</a>
<a href="/admin/reviews">{{ t.nav_reviews }}</a>
<a href="/admin/users">{{ t.nav_users }}</a>
<a href="/admin/settings">{{ t.nav_settings }}</a>
<a href="/admin/debug">{{ t.nav_debug }}</a>
+51
View File
@@ -0,0 +1,51 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_media_files }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.media_files_heading }}</h1>
{% if rows.is_empty() %}
<p>{{ t.media_files_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.media_files_filename }}</th>
<th>{{ t.media_files_type }}</th>
<th>{{ t.media_files_format }}</th>
<th>{{ t.media_files_size }}</th>
<th>{{ t.media_files_track }}</th>
<th>{{ t.media_files_path }}</th>
<th>{{ t.media_files_created }}</th>
<th>{{ t.media_files_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.media_file.id_val() }}</td>
<td>{{ row.media_file.original_filename_str() }}</td>
<td>{{ row.media_file.file_type_str() }}</td>
<td>{{ row.media_file.audio_format_str() }}</td>
<td>{{ row.media_file.file_size_display() }}</td>
<td>
{% if row.track_title.is_empty() %}
<span class="badge badge-orphan">{{ t.media_files_orphan }}</span>
{% else %}
{{ row.track_title }}
{% endif %}
</td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.media_file.file_path_str() }}">{{ row.media_file.file_path_str() }}</td>
<td>{{ row.media_file.created_at_str() }}</td>
<td>
<form method="post" action="/admin/media-files/{{ row.media_file.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.media_files_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.media_files_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<style>
.badge-orphan { background: #fff3cd; color: #856404; }
</style>
{% endblock content %}
+32
View File
@@ -0,0 +1,32 @@
{% if !agent_enabled %}
<p style="color:#999;">{{ t.settings_agent_status_disabled }}</p>
{% else if agent_llm_url.is_empty() %}
<p style="color:#999;">{{ t.settings_agent_status_no_url }}</p>
{% else if agent_probe.ok %}
<div style="border:1px solid #28a745; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#f0fff0;">
<p style="margin:0 0 .5rem; font-weight:bold; color:#28a745;">{{ t.settings_agent_status_ok }}</p>
{% if !agent_probe.model_intro.is_empty() %}
<blockquote style="border-left:3px solid #28a745; padding-left:.75rem; margin:.5rem 0; color:#333; font-style:italic;">{{ agent_probe.model_intro }}</blockquote>
{% endif %}
<table style="font-size:.85rem; margin-top:.5rem;">
{% if !agent_probe.model_name.is_empty() %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_model_name }}</td><td><code>{{ agent_probe.model_name }}</code></td></tr>
{% endif %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_latency }}</td><td>{{ agent_probe.latency_ms }} ms</td></tr>
{% if let Some(pt) = agent_probe.prompt_tokens %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_prompt_tokens }}</td><td>{{ pt }}</td></tr>
{% endif %}
{% if let Some(ct) = agent_probe.completion_tokens %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_completion_tokens }}</td><td>{{ ct }}</td></tr>
{% endif %}
{% if let Some(tps) = agent_probe.tokens_per_sec %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_tokens_per_sec }}</td><td>{{ format!("{:.1}", tps) }}</td></tr>
{% endif %}
</table>
</div>
{% else %}
<div style="border:1px solid #dc3545; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#fff5f5;">
<p style="margin:0 0 .5rem; font-weight:bold; color:#dc3545;">{{ t.settings_agent_status_error }}</p>
<p style="margin:0; font-size:.85rem; color:#666;">{{ agent_probe.error }}</p>
</div>
{% endif %}
+130
View File
@@ -0,0 +1,130 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/releases/{{ form_release_id }}/edit{% else %}/admin/releases/new{% endif %}" id="release-form">
<table>
<tr>
<td><label for="title">{{ t.releases_title }}</label></td>
<td><input name="title" id="title" value="{{ form_title }}" required style="width:100%"></td>
</tr>
<tr>
<td><label for="release_type">{{ t.releases_type }}</label></td>
<td>
<select name="release_type" id="release_type" style="width:100%; padding:.4rem;">
{% for rt in release_types %}
<option value="{{ rt.0 }}"{% if form_release_type == rt.0 %} selected{% endif %}>{% if lang_code == "ru" %}{{ rt.2 }}{% else %}{{ rt.1 }}{% endif %} ({{ rt.0 }})</option>
{% endfor %}
</select>
</td>
</tr>
<tr>
<td><label for="year">{{ t.releases_year }}</label></td>
<td><input name="year" id="year" type="number" min="1900" max="2100" value="{{ form_year }}" style="width:100%"></td>
</tr>
<tr>
<td><label>{{ t.releases_artists }}</label></td>
<td>
<div id="artist-tags" style="display:flex; flex-wrap:wrap; gap:.3rem; margin-bottom:.5rem;"></div>
<div style="display:flex; gap:.5rem;">
<input list="artist-list" id="artist-input" placeholder="{{ t.releases_select_artist }}" style="flex:1; padding:.4rem;">
<datalist id="artist-list">
{% for a in artists %}
<option value="{{ a.name_str() }}" data-id="{{ a.id_val() }}"></option>
{% endfor %}
</datalist>
<button type="button" onclick="addArtist()" style="padding:.4rem .8rem;">+</button>
</div>
<input type="hidden" name="artist_id" id="artist-ids-hidden" value="">
</td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
<script>
(function() {
// Artist data from server
var allArtists = [
{% for a in artists %}
{ id: {{ a.id_val() }}, name: "{{ a.name_str() }}" },
{% endfor %}
];
// Currently selected artist IDs
var selectedIds = [{% for aid in form_artist_ids %}{{ aid }},{% endfor %}];
var tagsContainer = document.getElementById('artist-tags');
var hiddenInput = document.getElementById('artist-ids-hidden');
var artistInput = document.getElementById('artist-input');
function findArtistByName(name) {
var lower = name.toLowerCase().trim();
for (var i = 0; i < allArtists.length; i++) {
if (allArtists[i].name.toLowerCase() === lower) return allArtists[i];
}
return null;
}
function findArtistById(id) {
for (var i = 0; i < allArtists.length; i++) {
if (allArtists[i].id === id) return allArtists[i];
}
return null;
}
function syncHidden() {
hiddenInput.value = selectedIds.join(',');
}
function renderTags() {
tagsContainer.innerHTML = '';
for (var i = 0; i < selectedIds.length; i++) {
var artist = findArtistById(selectedIds[i]);
if (!artist) continue;
var tag = document.createElement('span');
tag.style.cssText = 'display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .5rem; background:#e9ecef; border-radius:4px; font-size:.85rem;';
tag.textContent = artist.name;
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '\u00d7';
btn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:1rem; color:#c00; padding:0; line-height:1;';
btn.setAttribute('data-id', artist.id);
btn.onclick = function() {
var rid = parseInt(this.getAttribute('data-id'));
selectedIds = selectedIds.filter(function(x) { return x !== rid; });
renderTags();
syncHidden();
};
tag.appendChild(btn);
tagsContainer.appendChild(tag);
}
}
window.addArtist = function() {
var artist = findArtistByName(artistInput.value);
if (!artist) return;
if (selectedIds.indexOf(artist.id) === -1) {
selectedIds.push(artist.id);
renderTags();
syncHidden();
}
artistInput.value = '';
};
// Allow pressing Enter in the artist input to add
artistInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
window.addArtist();
}
});
// Initial render
renderTags();
syncHidden();
})();
</script>
{% endblock content %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_releases }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.releases_heading }}</h1>
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:1rem; flex-wrap:wrap;">
<a href="/admin/releases/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.releases_add }}</a>
<form method="get" action="/admin/releases" style="display:flex; gap:.5rem; align-items:center;">
<label for="artist_id" style="font-size:.85rem; color:#555;">{{ t.releases_filter_label }}:</label>
<select name="artist_id" id="artist_id" onchange="this.form.submit()" style="padding:.35rem .5rem; border:1px solid #ccc; border-radius:4px;">
<option value="">{{ t.releases_filter_all }}</option>
{% for a in artists %}
<option value="{{ a.id_val() }}"{% match filter_artist_id %}{% when Some with (fid) %}{% if *fid == a.id_val() %} selected{% endif %}{% when None %}{% endmatch %}>{{ a.name_str() }}</option>
{% endfor %}
</select>
</form>
</div>
{% if rows.is_empty() %}
<p>{{ t.releases_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.releases_title }}</th>
<th>{{ t.releases_artists }}</th>
<th>{{ t.releases_type }}</th>
<th>{{ t.releases_year }}</th>
<th>{{ t.releases_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.release.id_val() }}</td>
<td>{{ row.release.title_str() }}</td>
<td>{% if row.artist_names.is_empty() %}<span style="color:#999;">{{ t.releases_no_artist }}</span>{% else %}{{ row.artist_names }}{% endif %}</td>
<td><code>{{ row.release.release_type_str() }}</code></td>
<td>{{ row.release.year_display() }}</td>
<td>
<a href="/admin/releases/{{ row.release.id_val() }}/edit">{{ t.releases_edit }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/releases/{{ row.release.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.releases_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.releases_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock content %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "admin/layout.html" %}
{% block admin_title %}Review #{{ review.id_val() }}{% endblock admin_title %}
{% block content %}
<h1>Review #{{ review.id_val() }}</h1>
<table>
<tr><th>{{ t.reviews_status }}</th><td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td></tr>
<tr><th>{{ t.reviews_type }}</th><td>{{ review.review_type_str() }}</td></tr>
<tr><th>{{ t.reviews_input_path }}</th><td style="word-break:break-all;">{{ review.input_path_str() }}</td></tr>
<tr><th>{{ t.reviews_confidence }}</th><td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td></tr>
<tr><th>{{ t.reviews_created }}</th><td>{{ review.created_at_str() }}</td></tr>
{% match stats %}
{% when Some with (s) %}
<tr><th>{{ t.reviews_model }}</th><td>{{ s.model_name_str() }}</td></tr>
<tr><th>{{ t.reviews_llm_duration }}</th><td>{{ s.duration_display() }}</td></tr>
<tr><th>{{ t.reviews_tokens }}</th><td>{{ s.tokens_display() }}</td></tr>
{% when None %}
{% endmatch %}
</table>
{% if !error_message.is_empty() %}
<div style="margin: 1rem 0; padding: 1rem; background: #f8d7da; color: #721c24; border-radius: 6px;">
<strong>{{ t.reviews_error }}:</strong> {{ error_message }}
</div>
{% endif %}
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
{% if review.status_str() == "pending" %}
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="display:inline;">
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
</form>
<form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;">
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button>
</form>
{% endif %}
{% if review.status_str() == "failed" || review.status_str() == "processing" %}
<form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');">
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
</form>
{% endif %}
</div>
{% if !context_pretty.is_empty() %}
<h2>{{ t.reviews_context }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
{% endif %}
{% if !result_pretty.is_empty() %}
<h2>{{ t.reviews_result }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/reviews">&larr; {{ t.reviews_back_to_list }}</a></p>
<style>
.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>
{% endblock content %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_reviews }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.reviews_heading }}</h1>
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
<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>
<a href="/admin/reviews?status=rejected" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "rejected" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_rejected }}</a>
<a href="/admin/reviews?status=queued" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "queued" %} #17a2b8; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_queued }}</a>
<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() %}
<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>
</form>
{% endif %}
</div>
{% if reviews.is_empty() %}
<p>{{ t.reviews_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.reviews_status }}</th>
<th>{{ t.reviews_type }}</th>
<th>{{ t.reviews_input_path }}</th>
<th>{{ t.reviews_confidence }}</th>
<th>{{ t.reviews_model }}</th>
<th>{{ t.reviews_llm_duration }}</th>
<th>{{ t.reviews_tokens }}</th>
<th>{{ t.reviews_created }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for review in reviews %}
<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()) %}
{% when Some with (s) %}
<td>{{ s.model_name }}</td>
<td>{{ s.duration_display() }}</td>
<td>{{ s.tokens_display() }}</td>
{% when None %}
<td>-</td>
<td>-</td>
<td>-</td>
{% endmatch %}
<td>{{ review.created_at_str() }}</td>
<td>
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<style>
.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>
{% endblock content %}
+64
View File
@@ -82,6 +82,70 @@
</tr>
</table>
<h2>{{ t.settings_agent }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_agent_help }}</p>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="agent_enabled">{{ t.settings_agent_enabled }}</label></td>
<td><input type="checkbox" name="agent_enabled" id="agent_enabled" value="on"{% if agent_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ agent_enabled_source }}">{{ agent_enabled_source }}</span></td>
</tr>
<tr>
<td><label for="agent_inbox_dir">{{ t.settings_agent_inbox }}</label></td>
<td><input name="agent_inbox_dir" id="agent_inbox_dir" value="{{ agent_inbox_dir }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_inbox_dir_source }}">{{ agent_inbox_dir_source }}</span></td>
</tr>
<tr>
<td><label for="agent_storage_dir">{{ t.settings_agent_storage }}</label></td>
<td><input name="agent_storage_dir" id="agent_storage_dir" value="{{ agent_storage_dir }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_storage_dir_source }}">{{ agent_storage_dir_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_url">{{ t.settings_agent_llm_url }}</label></td>
<td><input name="agent_llm_url" id="agent_llm_url" value="{{ agent_llm_url }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_url_source }}">{{ agent_llm_url_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_model">{{ t.settings_agent_llm_model }}</label></td>
<td><input name="agent_llm_model" id="agent_llm_model" value="{{ agent_llm_model }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_model_source }}">{{ agent_llm_model_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_auth">{{ t.settings_agent_llm_auth }}</label></td>
<td><input name="agent_llm_auth" id="agent_llm_auth" type="password" value="{{ agent_llm_auth }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_auth_source }}">{{ agent_llm_auth_source }}</span></td>
</tr>
<tr>
<td><label for="agent_confidence_threshold">{{ t.settings_agent_threshold }}</label></td>
<td><input name="agent_confidence_threshold" id="agent_confidence_threshold" value="{{ agent_confidence_threshold }}" style="width:6em"></td>
<td><span class="badge badge-{{ agent_confidence_threshold_source }}">{{ agent_confidence_threshold_source }}</span></td>
</tr>
<tr>
<td><label for="agent_context_limit">{{ t.settings_agent_context }}</label></td>
<td><input name="agent_context_limit" id="agent_context_limit" value="{{ agent_context_limit }}" style="width:6em"></td>
<td><span class="badge badge-{{ agent_context_limit_source }}">{{ agent_context_limit_source }}</span></td>
</tr>
<tr>
<td><label for="agent_concurrency">{{ t.settings_agent_concurrency }}</label></td>
<td><input name="agent_concurrency" id="agent_concurrency" value="{{ agent_concurrency }}" type="number" min="1" max="32" style="width:6em"></td>
<td><span class="badge badge-{{ agent_concurrency_source }}">{{ agent_concurrency_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_agent_status }}</h2>
<div hx-get="/admin/settings/probe" hx-trigger="load" hx-swap="innerHTML">
<p style="color:#999;">
<span class="htmx-indicator" style="display:inline;">
&#9696; {{ t.settings_agent_status_loading }}...
</span>
</p>
</div>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% endblock content %}