4373 lines
172 KiB
HTML
4373 lines
172 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}FuruMusic Admin v{{ version }}{% endblock title %}
|
|
|
|
{% block head_extra %}
|
|
<style>
|
|
:root {
|
|
--bg-primary: #121212;
|
|
--bg-secondary: #181818;
|
|
--bg-elevated: #232323;
|
|
--bg-hover: #2a2a2a;
|
|
--bg-active: #333;
|
|
--text-primary: #fff;
|
|
--text-secondary: #b3b3b3;
|
|
--text-subdued: #727272;
|
|
--accent: #1db954;
|
|
--accent-hover: #1ed760;
|
|
--blue: #5aa7ff;
|
|
--amber: #f1b84b;
|
|
--red: #ef6262;
|
|
--violet: #b98cff;
|
|
--border-color: #2b2b2b;
|
|
--sidebar-width: 244px;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
[x-cloak] { display: none !important; }
|
|
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-width: 1180px;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
|
}
|
|
|
|
button, input, select, textarea {
|
|
font: inherit;
|
|
}
|
|
|
|
button {
|
|
border: 0;
|
|
}
|
|
|
|
.admin-shell {
|
|
display: grid;
|
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar {
|
|
min-width: 0;
|
|
border-right: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.brand {
|
|
padding: 18px 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.brand-title {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.version {
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.admin-user {
|
|
margin-top: 8px;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.nav-group {
|
|
padding: 12px;
|
|
}
|
|
|
|
.nav-label {
|
|
padding: 8px 8px 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 100%;
|
|
height: 38px;
|
|
padding: 0 10px;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
display: grid;
|
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
.nav-btn:hover,
|
|
.nav-btn.active {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.nav-btn svg,
|
|
.icon-btn svg,
|
|
.btn svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.nav-count {
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.main {
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.topbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 5;
|
|
height: 64px;
|
|
padding: 12px 18px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: rgba(18, 18, 18, 0.96);
|
|
backdrop-filter: blur(12px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.page-title {
|
|
min-width: 0;
|
|
}
|
|
|
|
.page-title h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.page-title p {
|
|
margin: 4px 0 0;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.top-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.content {
|
|
padding: 18px;
|
|
}
|
|
|
|
.stats-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.stat-cell {
|
|
min-width: 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.stat-label {
|
|
margin-top: 2px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.panel {
|
|
min-width: 0;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.panel-head {
|
|
min-height: 56px;
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.panel-title {
|
|
min-width: 0;
|
|
}
|
|
|
|
.panel-title strong {
|
|
display: block;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.panel-title span {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn,
|
|
.icon-btn,
|
|
.seg-btn {
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-secondary);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 7px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0 12px;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 32px;
|
|
padding: 0;
|
|
}
|
|
|
|
.btn:hover,
|
|
.icon-btn:hover,
|
|
.seg-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.btn.primary {
|
|
background: var(--accent);
|
|
color: #07120b;
|
|
}
|
|
|
|
.btn.primary:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
.btn.danger {
|
|
background: rgba(239, 98, 98, 0.18);
|
|
color: #ffb8b8;
|
|
}
|
|
|
|
.btn.warn {
|
|
background: rgba(241, 184, 75, 0.16);
|
|
color: #ffd891;
|
|
}
|
|
|
|
.btn:disabled,
|
|
.icon-btn:disabled,
|
|
.seg-btn:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.segmented {
|
|
display: inline-flex;
|
|
padding: 3px;
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.seg-btn {
|
|
min-width: 76px;
|
|
height: 28px;
|
|
padding: 0 10px;
|
|
background: transparent;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.seg-btn.active {
|
|
background: var(--bg-elevated);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.search {
|
|
width: 260px;
|
|
height: 32px;
|
|
padding: 0 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
}
|
|
|
|
.search:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.action-strip {
|
|
min-height: 44px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.selection-summary {
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.table-wrap {
|
|
max-height: 560px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.review-table-wrap {
|
|
max-height: calc(100vh - 318px);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 2;
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-subdued);
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
text-align: left;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
td {
|
|
height: 54px;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
tr {
|
|
cursor: default;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: rgba(255, 255, 255, 0.035);
|
|
}
|
|
|
|
.col-check { width: 38px; }
|
|
.col-status { width: 112px; }
|
|
.col-type { width: 120px; }
|
|
.col-confidence { width: 88px; }
|
|
.col-tags { width: 250px; }
|
|
.col-time { width: 132px; }
|
|
|
|
.users-table-wrap {
|
|
max-height: calc(100vh - 300px);
|
|
}
|
|
|
|
.users-table .user-status-column { width: 116px; }
|
|
.users-table .user-role-column { width: 110px; }
|
|
.users-table .user-active-column { width: 112px; }
|
|
.users-table .user-seen-column { width: 170px; }
|
|
|
|
.check {
|
|
width: 16px;
|
|
height: 16px;
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
.primary-line {
|
|
color: var(--text-primary);
|
|
font-weight: 750;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.secondary-line {
|
|
margin-top: 3px;
|
|
color: var(--text-subdued);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 22px;
|
|
padding: 0 8px;
|
|
border-radius: 999px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.badge.queued { background: rgba(90, 167, 255, 0.16); color: #9ccbff; }
|
|
.badge.processing { background: rgba(185, 140, 255, 0.18); color: #d8c2ff; }
|
|
.badge.pending { background: rgba(241, 184, 75, 0.18); color: #ffe1a6; }
|
|
.badge.failed,
|
|
.badge.rejected { background: rgba(239, 98, 98, 0.18); color: #ffb7b7; }
|
|
.badge.approved,
|
|
.badge.auto_approved,
|
|
.badge.completed,
|
|
.badge.ok { background: rgba(29, 185, 84, 0.16); color: #8ef0b2; }
|
|
.badge.running { background: rgba(185, 140, 255, 0.18); color: #d8c2ff; }
|
|
.badge.disabled { background: rgba(255, 255, 255, 0.08); color: var(--text-subdued); }
|
|
|
|
.tags {
|
|
display: flex;
|
|
gap: 5px;
|
|
flex-wrap: wrap;
|
|
max-height: 42px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 20px;
|
|
padding: 0 7px;
|
|
border-radius: 999px;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
color: var(--text-primary);
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.tag.format { background: rgba(90, 167, 255, 0.22); color: #b8d9ff; }
|
|
.tag.bitrate { background: rgba(185, 140, 255, 0.22); color: #dfcbff; }
|
|
.tag.sample,
|
|
.tag.depth { background: rgba(29, 185, 84, 0.18); color: #9af0b8; }
|
|
.tag.size { background: rgba(255, 255, 255, 0.1); color: #d2d2d2; }
|
|
.tag.count { background: rgba(90, 167, 255, 0.18); color: #b8d9ff; }
|
|
.tag.relation { background: rgba(241, 184, 75, 0.18); color: #ffe1a6; }
|
|
.tag.plays,
|
|
.tag.followers { background: rgba(185, 140, 255, 0.18); color: #d8c2ff; }
|
|
.tag.visibility { background: rgba(29, 185, 84, 0.16); color: #8ef0b2; }
|
|
.tag.metadata-lastfm { background: rgba(90, 167, 255, 0.18); color: #c7e0ff; }
|
|
.tag.metadata-file { background: rgba(29, 185, 84, 0.16); color: #9af0b8; }
|
|
.tag.metadata-review { background: rgba(241, 184, 75, 0.18); color: #ffe1a6; }
|
|
.tag.metadata-track-genre { background: rgba(255, 255, 255, 0.1); color: #d2d2d2; }
|
|
.tag.metadata-release-lastfm { background: rgba(90, 167, 255, 0.12); color: #b7d6ff; border: 1px solid rgba(90, 167, 255, 0.22); }
|
|
.tag.metadata-release-file { background: rgba(29, 185, 84, 0.1); color: #a7eabd; border: 1px solid rgba(29, 185, 84, 0.2); }
|
|
.tag.metadata-release-review { background: rgba(241, 184, 75, 0.12); color: #ffe1a6; border: 1px solid rgba(241, 184, 75, 0.22); }
|
|
|
|
.jobs-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.metadata-backfill-options {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.metadata-backfill-options .option-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.metadata-backfill-options label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-height: 26px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.metadata-backfill-options input {
|
|
width: auto;
|
|
min-height: auto;
|
|
}
|
|
|
|
.metadata-backfill-options .mode-row {
|
|
display: flex;
|
|
gap: 14px;
|
|
flex-wrap: wrap;
|
|
padding-top: 4px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.jobs-page {
|
|
display: grid;
|
|
grid-template-columns: minmax(720px, 1fr) 380px;
|
|
grid-template-rows: minmax(310px, 1fr) minmax(260px, 0.85fr);
|
|
gap: 14px;
|
|
align-items: stretch;
|
|
height: calc(100vh - 100px);
|
|
min-height: 0;
|
|
}
|
|
|
|
.jobs-page .jobs-list-panel {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.job-table-wrap {
|
|
flex: 1;
|
|
min-height: 0;
|
|
max-height: none;
|
|
overflow: auto;
|
|
}
|
|
|
|
.job-table {
|
|
table-layout: fixed;
|
|
}
|
|
|
|
.jobs-page .jobs-list-panel .panel-head {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.job-table .job-column { width: 32%; }
|
|
.job-table .state-column { width: 12%; }
|
|
.job-table .schedule-column { width: 22%; }
|
|
.job-table .runs-column { width: 24%; }
|
|
.job-table .actions-column { width: 10%; }
|
|
|
|
.job-table .toolbar {
|
|
flex-wrap: nowrap;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.inline-runs {
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
gap: 5px;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.run-chip {
|
|
gap: 5px;
|
|
max-width: 70px;
|
|
height: 20px;
|
|
padding: 0 7px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.task-form {
|
|
position: sticky;
|
|
top: 82px;
|
|
grid-column: 2;
|
|
grid-row: 1 / span 2;
|
|
align-self: start;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: calc(100vh - 100px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.task-form-body {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
padding: 12px;
|
|
}
|
|
|
|
.job-detail-description {
|
|
margin-bottom: 10px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.job-facts {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.job-fact {
|
|
display: grid;
|
|
grid-template-columns: 58px minmax(0, 1fr);
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-height: 28px;
|
|
padding: 6px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 7px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.job-fact span:first-child {
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.job-fact span:last-child {
|
|
min-width: 0;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.job-param-note {
|
|
margin: -2px 0 10px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.job-run-pager {
|
|
min-height: 34px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 8px 0 0;
|
|
}
|
|
|
|
.job-run-pager .toolbar {
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.run-log-panel {
|
|
grid-column: 1;
|
|
grid-row: 2;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.run-log-body {
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.run-log-output {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 210px;
|
|
margin: 0;
|
|
padding: 14px 16px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: #0b0d0f;
|
|
color: #dce6ef;
|
|
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow: auto;
|
|
tab-size: 4;
|
|
}
|
|
|
|
.job-card {
|
|
min-width: 0;
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.job-card:hover,
|
|
.job-card.active {
|
|
border-color: rgba(29, 185, 84, 0.55);
|
|
}
|
|
|
|
.job-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.job-name {
|
|
min-width: 0;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 850;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.job-desc {
|
|
height: 32px;
|
|
margin-top: 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
line-height: 16px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.job-meta {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.runs {
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.run-row {
|
|
display: grid;
|
|
grid-template-columns: 76px 96px minmax(0, 1fr) 72px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 42px;
|
|
padding: 6px 12px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.run-row:hover {
|
|
background: rgba(255, 255, 255, 0.035);
|
|
}
|
|
|
|
.library-shell {
|
|
display: block;
|
|
}
|
|
|
|
.settings-page {
|
|
max-width: none;
|
|
}
|
|
|
|
.settings-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px);
|
|
gap: 14px;
|
|
align-items: start;
|
|
}
|
|
|
|
.settings-column {
|
|
display: grid;
|
|
gap: 14px;
|
|
align-content: start;
|
|
}
|
|
|
|
.settings-side .settings-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.settings-actions {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.settings-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
padding: 14px;
|
|
}
|
|
|
|
.settings-card {
|
|
padding: 14px;
|
|
}
|
|
|
|
.setting-field {
|
|
min-width: 0;
|
|
}
|
|
|
|
.setting-field label,
|
|
.setting-toggle label {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
margin-bottom: 6px;
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.setting-field input {
|
|
width: 100%;
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
}
|
|
|
|
.setting-field input:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.setting-toggle {
|
|
min-height: 74px;
|
|
padding: 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.setting-toggle-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.setting-toggle-row span {
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.setting-toggle input {
|
|
width: 18px;
|
|
height: 18px;
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
.setting-help {
|
|
margin-top: 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.source-pill {
|
|
flex: 0 0 auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 18px;
|
|
padding: 0 6px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 850;
|
|
text-transform: lowercase;
|
|
}
|
|
|
|
.source-pill.env { background: rgba(90, 167, 255, 0.16); color: #9ccbff; }
|
|
.source-pill.database { background: rgba(29, 185, 84, 0.16); color: #8ef0b2; }
|
|
.source-pill.default { background: rgba(255, 255, 255, 0.08); color: var(--text-subdued); }
|
|
|
|
.settings-wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.settings-note {
|
|
padding: 14px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.probe-body {
|
|
padding: 14px;
|
|
}
|
|
|
|
.probe-intro {
|
|
margin: 0 0 12px;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.probe-table {
|
|
display: grid;
|
|
gap: 7px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.probe-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.library-row {
|
|
display: grid;
|
|
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-height: 58px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
}
|
|
|
|
.library-row:hover,
|
|
.library-row.active {
|
|
background: rgba(255, 255, 255, 0.035);
|
|
}
|
|
|
|
.field {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.field label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.field input,
|
|
.field textarea,
|
|
.field select {
|
|
width: 100%;
|
|
min-height: 34px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
resize: vertical;
|
|
outline: none;
|
|
}
|
|
|
|
.field textarea {
|
|
min-height: 110px;
|
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.image-editor {
|
|
display: grid;
|
|
grid-template-columns: 150px minmax(0, 1fr);
|
|
gap: 14px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.image-preview {
|
|
width: 150px;
|
|
aspect-ratio: 1;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
display: grid;
|
|
place-items: center;
|
|
overflow: hidden;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.image-preview img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.cover-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(86px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.cover-option {
|
|
min-width: 0;
|
|
padding: 6px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.cover-option:hover {
|
|
border-color: rgba(29, 185, 84, 0.55);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.cover-option img {
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
object-fit: cover;
|
|
border-radius: 5px;
|
|
display: block;
|
|
}
|
|
|
|
.cover-option span {
|
|
display: block;
|
|
margin-top: 5px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.artist-tags {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.run-log-output.is-following {
|
|
border-color: rgba(29, 185, 84, 0.36);
|
|
}
|
|
|
|
.metadata-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
max-height: 112px;
|
|
overflow: auto;
|
|
padding: 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.metadata-tags .tag {
|
|
height: auto;
|
|
min-height: 24px;
|
|
gap: 5px;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
.metadata-tags small {
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
opacity: 0.72;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.metadata-empty {
|
|
min-height: 34px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.artist-picker {
|
|
position: relative;
|
|
}
|
|
|
|
.artist-picker input {
|
|
width: 100%;
|
|
}
|
|
|
|
.artist-results {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: calc(100% + 5px);
|
|
z-index: 5;
|
|
max-height: 242px;
|
|
overflow: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.38);
|
|
}
|
|
|
|
.artist-result {
|
|
width: 100%;
|
|
min-height: 34px;
|
|
padding: 8px 10px;
|
|
border: 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.artist-result small {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.artist-result:hover,
|
|
.artist-result:focus {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.artist-result:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.tag button {
|
|
width: 16px;
|
|
height: 16px;
|
|
margin-left: 5px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.14);
|
|
color: inherit;
|
|
cursor: pointer;
|
|
line-height: 1;
|
|
}
|
|
|
|
.tag button:hover {
|
|
background: rgba(255, 255, 255, 0.24);
|
|
}
|
|
|
|
.editor-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.release-track-search-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.release-track-list {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.release-track-head,
|
|
.release-track-row {
|
|
display: grid;
|
|
grid-template-columns: 72px 82px minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .9fr) 70px 36px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 8px;
|
|
}
|
|
|
|
.release-track-head {
|
|
min-height: 34px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
color: var(--text-subdued);
|
|
font-size: 10px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.release-track-row {
|
|
min-height: 48px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
}
|
|
|
|
.release-track-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.release-track-row:hover {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.release-track-row input {
|
|
width: 100%;
|
|
height: 30px;
|
|
min-height: 30px;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.release-track-title,
|
|
.release-track-meta {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.release-track-title {
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.release-track-meta {
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.image-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.file-name {
|
|
max-width: 280px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cover-option.active {
|
|
border-color: rgba(29, 185, 84, 0.8);
|
|
box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.32);
|
|
}
|
|
|
|
.muted {
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.empty {
|
|
padding: 28px;
|
|
color: var(--text-subdued);
|
|
text-align: center;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
right: 18px;
|
|
bottom: 18px;
|
|
z-index: 20;
|
|
max-width: 420px;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-primary);
|
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.loading-mask {
|
|
position: absolute;
|
|
inset: 64px 0 0 var(--sidebar-width);
|
|
z-index: 10;
|
|
display: grid;
|
|
place-items: center;
|
|
background: rgba(18, 18, 18, 0.68);
|
|
}
|
|
|
|
.modal-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 30;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 28px;
|
|
background: rgba(0, 0, 0, 0.62);
|
|
}
|
|
|
|
.modal {
|
|
width: min(760px, calc(100vw - 80px));
|
|
max-height: calc(100vh - 80px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.56);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.modal-head {
|
|
min-height: 58px;
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.modal-body {
|
|
max-height: calc(100vh - 154px);
|
|
padding: 14px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.user-modal {
|
|
width: min(860px, calc(100vw - 80px));
|
|
}
|
|
|
|
.user-modal-tabs {
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.user-summary-grid,
|
|
.user-profile-facts {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.user-summary-card,
|
|
.user-profile-facts > div {
|
|
min-width: 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.user-summary-card span,
|
|
.user-profile-facts span {
|
|
display: block;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.user-summary-card strong,
|
|
.user-profile-facts strong {
|
|
display: block;
|
|
overflow: hidden;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.user-stats-grid {
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
}
|
|
|
|
.user-activity-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.user-activity-row {
|
|
min-width: 0;
|
|
padding: 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
display: grid;
|
|
grid-template-columns: 52px minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-activity-cover {
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: 4px;
|
|
background: var(--bg-elevated);
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.user-activity-cover img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.user-activity-cover svg {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
.user-activity-main {
|
|
min-width: 0;
|
|
}
|
|
|
|
.user-activity-title,
|
|
.user-activity-meta,
|
|
.user-activity-sub {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.user-activity-title {
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.user-activity-meta {
|
|
margin-top: 3px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.user-activity-sub {
|
|
margin-top: 3px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.user-activity-time {
|
|
min-width: 118px;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
text-align: right;
|
|
}
|
|
|
|
.user-activity-time strong {
|
|
display: block;
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
}
|
|
</style>
|
|
{% endblock head_extra %}
|
|
|
|
{% block content %}
|
|
<div class="admin-shell" x-data="adminV2()" x-init="init()" x-cloak>
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<div class="brand-title">
|
|
<span>FuruMusic Admin</span>
|
|
<span class="version">v{{ version }}</span>
|
|
</div>
|
|
<div class="admin-user">{{ user_name }} - {{ user_role }}</div>
|
|
</div>
|
|
|
|
<div class="nav-group">
|
|
<div class="nav-label">Operations</div>
|
|
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
|
<i data-lucide="inbox"></i>
|
|
<span>Review Queue</span>
|
|
<span class="nav-count" x-text="reviewTotalAll()"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
|
<i data-lucide="calendar-clock"></i>
|
|
<span>Tasks</span>
|
|
<span class="nav-count" x-text="jobs.length || 0"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'library'}" @click="openLibrary(libraryKind)">
|
|
<i data-lucide="library"></i>
|
|
<span>Library Workbench</span>
|
|
<span class="nav-count" x-text="fmt(stats.tracks || 0)"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="openTools()">
|
|
<i data-lucide="wrench"></i>
|
|
<span>Future Tools</span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
|
|
<i data-lucide="users"></i>
|
|
<span>Users</span>
|
|
<span class="nav-count" x-text="users.online_count ? users.online_count + ' online' : ''"></span>
|
|
</button>
|
|
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
|
<i data-lucide="settings"></i>
|
|
<span>Settings</span>
|
|
<span class="nav-count" x-text="settings.lastfm_scrobbling_configured ? 'ok' : ''"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="nav-group">
|
|
<div class="nav-label">Entities</div>
|
|
<button class="nav-btn" @click="openLibrary('artists')">
|
|
<i data-lucide="mic-2"></i>
|
|
<span>Artists</span>
|
|
<span class="nav-count" x-text="fmt(libraryOverview.artists || 0)"></span>
|
|
</button>
|
|
<button class="nav-btn" @click="openLibrary('releases')">
|
|
<i data-lucide="disc-3"></i>
|
|
<span>Releases</span>
|
|
<span class="nav-count" x-text="fmt(libraryOverview.releases || 0)"></span>
|
|
</button>
|
|
<button class="nav-btn" @click="openLibrary('playlists')">
|
|
<i data-lucide="list-music"></i>
|
|
<span>Playlists</span>
|
|
<span class="nav-count" x-text="fmt(libraryOverview.playlists || 0)"></span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="main">
|
|
<div class="topbar">
|
|
<div class="page-title">
|
|
<h1 x-text="pageTitle()"></h1>
|
|
<p x-text="pageSubtitle()"></p>
|
|
</div>
|
|
<div class="top-actions">
|
|
<button class="btn" @click="refreshAll()">
|
|
<i data-lucide="refresh-cw"></i>
|
|
Refresh
|
|
</button>
|
|
<a class="btn" href="/admin/debug">
|
|
<i data-lucide="bug"></i>
|
|
Debug
|
|
</a>
|
|
<a class="btn" href="/">
|
|
<i data-lucide="music-2"></i>
|
|
Player
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === 'reviews'">
|
|
<section class="stats-strip">
|
|
<template x-for="cell in statCells()" :key="cell.label">
|
|
<div class="stat-cell" :title="cell.title || ''">
|
|
<div class="stat-value" x-text="cell.display || fmt(cell.value)"></div>
|
|
<div class="stat-label" x-text="cell.label"></div>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
|
|
<div>
|
|
<section class="panel jobs-list-panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Pending Reviews</strong>
|
|
<span x-text="reviewPanelSubtitle()"></span>
|
|
</div>
|
|
<div class="toolbar">
|
|
<div class="segmented">
|
|
<template x-for="status in reviewStatuses" :key="status.value">
|
|
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
|
|
<span x-text="status.label"></span>
|
|
<span class="muted" x-text="status.value ? statusCount(status.value) : reviewTotalAll()"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
<input class="search" placeholder="Search queue" x-model="reviewFilter.search" @input.debounce.350ms="loadReviews()" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-strip">
|
|
<div class="toolbar">
|
|
<button class="btn" @click="selectVisibleReviews()">
|
|
<i data-lucide="check-square"></i>
|
|
Select visible
|
|
</button>
|
|
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
|
|
<i data-lucide="list-checks"></i>
|
|
Select filter
|
|
</button>
|
|
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
|
|
<i data-lucide="x"></i>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<div class="toolbar">
|
|
<span class="selection-summary" x-text="reviewSelectionSummary()"></span>
|
|
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="selectedReviewCount() === 0">
|
|
<i data-lucide="rotate-ccw"></i>
|
|
Requeue selected
|
|
</button>
|
|
<button class="btn danger" @click="bulkReviews('delete')" :disabled="selectedReviewCount() === 0">
|
|
<i data-lucide="trash-2"></i>
|
|
Delete selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-wrap review-table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="col-check"></th>
|
|
<th class="col-status">Status</th>
|
|
<th>Input</th>
|
|
<th class="col-type">Type</th>
|
|
<th class="col-confidence">Conf.</th>
|
|
<th class="col-tags">Tags</th>
|
|
<th class="col-time">Updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="row in reviews.items" :key="row.id">
|
|
<tr @click="openReview(row)">
|
|
<td class="col-check" @click.stop>
|
|
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" />
|
|
</td>
|
|
<td><span class="badge" :class="row.status" x-text="row.status"></span></td>
|
|
<td>
|
|
<div class="primary-line" :title="row.input_path" x-text="row.filename || row.display_path"></div>
|
|
<div class="secondary-line" :title="row.input_path" x-text="row.display_path"></div>
|
|
</td>
|
|
<td><span x-text="row.review_type"></span></td>
|
|
<td><span x-text="formatConfidence(row.confidence)"></span></td>
|
|
<td>
|
|
<div class="tags">
|
|
<template x-for="tag in row.tags" :key="tag.kind + tag.label">
|
|
<span class="tag" :class="tag.kind" x-text="tag.label"></span>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
<td><span x-text="shortDate(row.updated_at)"></span></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
<div class="empty" x-show="!loading && reviews.items.length === 0">No reviews in this filter</div>
|
|
</div>
|
|
<div class="action-strip">
|
|
<span class="selection-summary" x-text="reviewRangeText()"></span>
|
|
<div class="toolbar">
|
|
<select class="search" style="width:92px" x-model.number="reviews.limit" @change="reviews.offset = 0; loadReviews(false)">
|
|
<option value="40">40</option>
|
|
<option value="80">80</option>
|
|
<option value="150">150</option>
|
|
<option value="250">250</option>
|
|
</select>
|
|
<button class="btn" @click="pageReviews(-1)" :disabled="reviews.offset === 0">Previous</button>
|
|
<button class="btn" @click="pageReviews(1)" :disabled="reviews.offset + reviews.limit >= reviews.total">Next</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === 'jobs'">
|
|
<div class="jobs-page">
|
|
<section class="panel jobs-list-panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Tasks</strong>
|
|
<span x-text="jobPanelSubtitle()"></span>
|
|
</div>
|
|
<div class="toolbar">
|
|
<button class="btn" @click="loadJobs()">
|
|
<i data-lucide="refresh-cw"></i>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="job-table-wrap">
|
|
<table class="job-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="job-column">Task</th>
|
|
<th class="state-column">State</th>
|
|
<th class="schedule-column">Schedule</th>
|
|
<th class="runs-column">Latest Runs</th>
|
|
<th class="actions-column">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="job in jobs" :key="job.name">
|
|
<tr :class="{active: activeJob && activeJob.name === job.name}" @click="selectJob(job.name)">
|
|
<td>
|
|
<div class="primary-line" x-text="job.name"></div>
|
|
<div class="secondary-line" x-text="job.description"></div>
|
|
</td>
|
|
<td><span class="badge" :class="job.health" x-text="job.health"></span></td>
|
|
<td>
|
|
<div class="primary-line" x-text="'Next ' + relativeDate(job.next_run_at)"></div>
|
|
<div class="secondary-line" x-text="'Last ' + relativeDate(job.last_run_at)"></div>
|
|
</td>
|
|
<td>
|
|
<div class="inline-runs">
|
|
<template x-for="run in (job.recent_runs || []).slice(0, 3)" :key="run.id">
|
|
<button class="badge run-chip" :class="run.status" @click.stop="loadRunDetail(run)" :title="runTitle(run)">
|
|
<span x-text="runChipLabel(run)"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="toolbar">
|
|
<button class="icon-btn" type="button" @click.stop="runJob(job)" :disabled="Boolean(job.launching)" title="Run now">
|
|
<i data-lucide="play"></i>
|
|
</button>
|
|
<button class="icon-btn" type="button" @click.stop="toggleJob(job)" :title="job.enabled ? 'Disable' : 'Enable'">
|
|
<i :data-lucide="job.enabled ? 'pause' : 'power'"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel task-form">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong x-text="activeJob ? activeJob.name : 'Task Details'"></strong>
|
|
<span x-text="activeJob ? activeJob.health : 'Select a task'"></span>
|
|
</div>
|
|
</div>
|
|
<div class="task-form-body" x-show="activeJob">
|
|
<div class="job-detail-description" x-text="activeJob?.description || ''"></div>
|
|
<div class="job-facts">
|
|
<div class="job-fact">
|
|
<span>Cron</span>
|
|
<span :title="activeJob?.cron_expression || ''" x-text="cronLabel(activeJob)"></span>
|
|
</div>
|
|
<div class="job-fact">
|
|
<span>Next</span>
|
|
<span x-text="relativeDate(activeJob?.next_run_at)"></span>
|
|
</div>
|
|
<div class="job-fact">
|
|
<span>Last</span>
|
|
<span x-text="relativeDate(activeJob?.last_run_at)"></span>
|
|
</div>
|
|
</div>
|
|
<div class="metadata-backfill-options" x-show="isMetadataBackfillJob(activeJob)">
|
|
<div class="option-grid">
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.audio_bitrate" /> audio_bitrate</label>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.audio_sample_rate" /> audio_sample_rate</label>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.audio_bit_depth" /> audio_bit_depth</label>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.duration_seconds" /> duration_seconds</label>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.local_genres" /> local genres from files</label>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.lastfm_tags" /> Last.fm tags</label>
|
|
<label><input type="checkbox" x-model="metadataBackfillOptions.musicbrainz_tags" /> MusicBrainz tags</label>
|
|
</div>
|
|
<div class="mode-row">
|
|
<label><input type="radio" value="fill_missing" x-model="metadataBackfillOptions.mode" /> Fill missing only</label>
|
|
<label><input type="radio" value="overwrite" x-model="metadataBackfillOptions.mode" /> Overwrite existing values</label>
|
|
</div>
|
|
</div>
|
|
<div class="metadata-backfill-options" x-show="isArtworkBackfillJob(activeJob)">
|
|
<div class="mode-row">
|
|
<label><input type="radio" value="missing" x-model="artworkBackfillOptions.mode" /> Missing images only</label>
|
|
<label><input type="radio" value="overwrite" x-model="artworkBackfillOptions.mode" /> Search all and replace existing</label>
|
|
</div>
|
|
</div>
|
|
<div class="job-param-note" x-show="activeJob && !jobHasParameterForm(activeJob)">
|
|
This task has no manual parameters.
|
|
</div>
|
|
<div class="toolbar" style="margin-bottom:12px">
|
|
<button class="btn primary" type="button" @click="runJob(activeJob)">
|
|
<i data-lucide="play"></i>
|
|
<span x-text="isMetadataBackfillJob(activeJob) ? 'Run with options' : 'Run now'"></span>
|
|
</button>
|
|
<button class="btn" type="button" @click="toggleJob(activeJob)">
|
|
<i data-lucide="power"></i>
|
|
Toggle
|
|
</button>
|
|
</div>
|
|
<div class="field">
|
|
<label>Recent Runs</label>
|
|
<div class="runs">
|
|
<template x-for="run in pagedVisibleRuns()" :key="run.id">
|
|
<div class="run-row" @click="loadRunDetail(run)">
|
|
<span class="badge" :class="run.status" x-text="run.status"></span>
|
|
<span x-text="'#' + run.id"></span>
|
|
<span class="secondary-line" x-text="run.error_message || run.log_excerpt || run.trigger"></span>
|
|
<span x-text="duration(run.duration_ms)"></span>
|
|
</div>
|
|
</template>
|
|
<div class="empty" x-show="visibleRuns().length === 0">No runs for this task yet</div>
|
|
</div>
|
|
<div class="job-run-pager" x-show="visibleRuns().length > recentRunsPerPage">
|
|
<span class="selection-summary" x-text="recentRunsRangeText()"></span>
|
|
<div class="toolbar">
|
|
<button class="btn" type="button" @click="pageRecentRuns(-1)" :disabled="recentRunsPage === 0">Previous</button>
|
|
<button class="btn" type="button" @click="pageRecentRuns(1)" :disabled="(recentRunsPage + 1) * recentRunsPerPage >= visibleRuns().length">Next</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="empty" x-show="!activeJob">Select a task from the table</div>
|
|
</section>
|
|
|
|
<section class="panel run-log-panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Selected Run Log</strong>
|
|
<span x-text="activeRunDetail ? activeRunDetail.run.job_name + ' #' + activeRunDetail.run.id : 'Select a run chip or recent run row'"></span>
|
|
</div>
|
|
<div class="toolbar" x-show="activeRunDetail">
|
|
<span class="badge" :class="activeRunDetail?.run?.status" x-text="activeRunDetail?.run?.status"></span>
|
|
<span class="selection-summary" x-text="duration(activeRunDetail?.run?.duration_ms)"></span>
|
|
<button class="btn" type="button" x-show="!runLogAutoScroll" @click="enableRunLogFollow()">
|
|
<i data-lucide="chevrons-down"></i>
|
|
Follow
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="run-log-body">
|
|
<pre class="run-log-output" :class="{'is-following': runLogAutoScroll}" x-ref="runLogOutput" x-show="activeRunDetail" @scroll.passive="handleRunLogScroll()" x-text="activeRunDetail?.log_output || activeRunDetail?.run?.log_excerpt || 'No log output captured for this run.'"></pre>
|
|
<div class="empty" x-show="!activeRunDetail">No run selected</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === 'library'">
|
|
<div class="library-shell">
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Library Workbench</strong>
|
|
<span x-text="librarySubtitle()"></span>
|
|
</div>
|
|
<div class="toolbar">
|
|
<div class="segmented">
|
|
<button class="seg-btn" :class="{active: libraryKind === 'artists'}" @click="openLibrary('artists')">Artists</button>
|
|
<button class="seg-btn" :class="{active: libraryKind === 'releases'}" @click="openLibrary('releases')">Releases</button>
|
|
<button class="seg-btn" :class="{active: libraryKind === 'tracks'}" @click="openLibrary('tracks')">Tracks</button>
|
|
<button class="seg-btn" :class="{active: libraryKind === 'playlists'}" @click="openLibrary('playlists')">Playlists</button>
|
|
</div>
|
|
<input class="search" placeholder="Search library" x-model="librarySearch" @input.debounce.350ms="loadLibrary()" />
|
|
</div>
|
|
</div>
|
|
<div class="action-strip">
|
|
<div class="toolbar">
|
|
<button class="btn" @click="selectVisibleLibrary()">
|
|
<i data-lucide="check-square"></i>
|
|
Select visible
|
|
</button>
|
|
<button class="btn" @click="selectLibraryFilter()" :disabled="library.total === 0">
|
|
<i data-lucide="list-checks"></i>
|
|
Select filter
|
|
</button>
|
|
<button class="btn" @click="clearLibrarySelection()" :disabled="selectedLibraryCount() === 0">
|
|
<i data-lucide="x"></i>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<div class="toolbar">
|
|
<span class="selection-summary" x-text="librarySelectionSummary()"></span>
|
|
<button class="btn" @click="bulkLibrary('hide')" :disabled="selectedLibraryCount() === 0">
|
|
<i data-lucide="eye-off"></i>
|
|
Hide
|
|
</button>
|
|
<button class="btn" @click="bulkLibrary('show')" :disabled="selectedLibraryCount() === 0">
|
|
<i data-lucide="eye"></i>
|
|
Show
|
|
</button>
|
|
<button class="btn" @click="openEditor(activeLibraryItem)" :disabled="!activeLibraryItem">
|
|
<i data-lucide="square-pen"></i>
|
|
Edit
|
|
</button>
|
|
<button class="btn primary" x-show="libraryKind === 'releases'" @click="openReleaseCreator()">
|
|
<i data-lucide="plus"></i>
|
|
New release
|
|
</button>
|
|
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
|
|
<i data-lucide="git-merge"></i>
|
|
Merge
|
|
</button>
|
|
<button class="btn danger" @click="bulkLibrary('delete')" :disabled="selectedLibraryCount() === 0">
|
|
<i data-lucide="trash-2"></i>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<template x-for="item in library.items" :key="item.kind + item.id">
|
|
<div class="library-row" :class="{active: activeLibraryItem && activeLibraryItem.id === item.id && activeLibraryItem.kind === item.kind}" @click="openEditor(item)">
|
|
<input class="check" type="checkbox" :checked="isLibrarySelected(item)" @click.stop @change="toggleLibrary(item)" />
|
|
<div>
|
|
<div class="primary-line" x-text="item.title"></div>
|
|
<div class="secondary-line" x-text="item.subtitle || item.kind"></div>
|
|
</div>
|
|
<div class="tags">
|
|
<template x-for="tag in item.tags" :key="tag.kind + tag.label">
|
|
<span class="tag" :class="tag.kind" x-text="tag.label"></span>
|
|
</template>
|
|
</div>
|
|
<span class="badge" :class="item.is_hidden ? 'disabled' : 'ok'" x-text="item.is_hidden ? 'hidden' : 'visible'"></span>
|
|
</div>
|
|
</template>
|
|
<div class="empty" x-show="!libraryLoading && library.items.length === 0">No library rows in this filter</div>
|
|
</div>
|
|
<div class="action-strip">
|
|
<span class="selection-summary" x-text="libraryRangeText()"></span>
|
|
<div class="toolbar">
|
|
<select class="search" style="width:92px" x-model.number="library.limit" @change="library.offset = 0; loadLibrary(false)">
|
|
<option value="25">25</option>
|
|
<option value="40">40</option>
|
|
<option value="80">80</option>
|
|
<option value="120">120</option>
|
|
</select>
|
|
<button class="btn" @click="pageLibrary(-1)" :disabled="library.offset === 0">Previous</button>
|
|
<button class="btn" @click="pageLibrary(1)" :disabled="library.offset + library.limit >= library.total">Next</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === 'users'">
|
|
<section class="stats-strip">
|
|
<div class="stat-cell">
|
|
<div class="stat-value" x-text="fmt(users.total || 0)"></div>
|
|
<div class="stat-label">Users</div>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<div class="stat-value" x-text="fmt(users.online_count || 0)"></div>
|
|
<div class="stat-label">Online now</div>
|
|
</div>
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Users</strong>
|
|
<span x-text="usersSubtitle()"></span>
|
|
</div>
|
|
<div class="toolbar">
|
|
<input class="search" placeholder="Search users" x-model="userSearch" @input.debounce.350ms="loadUsers()" />
|
|
<button class="btn" @click="loadUsers(false)">
|
|
<i data-lucide="refresh-cw"></i>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap users-table-wrap">
|
|
<table class="users-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="user-status-column">Status</th>
|
|
<th>User</th>
|
|
<th class="user-role-column">Role</th>
|
|
<th class="user-active-column">Account</th>
|
|
<th class="user-seen-column">Last activity</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="user in users.items" :key="user.id">
|
|
<tr @click="openUser(user)">
|
|
<td>
|
|
<span class="badge" :class="userStatusClass(user)" x-text="userStatusLabel(user)"></span>
|
|
</td>
|
|
<td>
|
|
<div class="primary-line" x-text="user.display_name || user.username"></div>
|
|
<div class="secondary-line" x-text="user.email ? user.username + ' · ' + user.email : user.username"></div>
|
|
</td>
|
|
<td><span x-text="user.role"></span></td>
|
|
<td>
|
|
<span class="badge" :class="user.is_active ? 'ok' : 'disabled'" x-text="user.is_active ? 'active' : 'disabled'"></span>
|
|
</td>
|
|
<td><span x-text="userLastSeenLabel(user)"></span></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
<div class="empty" x-show="!usersLoading && users.items.length === 0">No users in this filter</div>
|
|
</div>
|
|
<div class="action-strip">
|
|
<span class="selection-summary" x-text="usersRangeText()"></span>
|
|
<div class="toolbar">
|
|
<select class="search" style="width:92px" x-model.number="users.limit" @change="users.offset = 0; loadUsers(false)">
|
|
<option value="25">25</option>
|
|
<option value="40">40</option>
|
|
<option value="80">80</option>
|
|
<option value="120">120</option>
|
|
</select>
|
|
<button class="btn" @click="pageUsers(-1)" :disabled="users.offset === 0">Previous</button>
|
|
<button class="btn" @click="pageUsers(1)" :disabled="users.offset + users.limit >= users.total">Next</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === 'tools'">
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Future Tools</strong>
|
|
<span>Reserved operation slots for complex library maintenance</span>
|
|
</div>
|
|
</div>
|
|
<div style="padding:14px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px">
|
|
<button class="btn" style="height:72px" @click="mockAction('Artist merge wizard placeholder')">
|
|
<i data-lucide="git-merge"></i>
|
|
Artist merge
|
|
</button>
|
|
<button class="btn" style="height:72px" @click="mockAction('Release split/move tracks placeholder')">
|
|
<i data-lucide="scissors"></i>
|
|
Split release
|
|
</button>
|
|
<button class="btn" style="height:72px" @click="mockAction('Metadata enrichment rerun placeholder')">
|
|
<i data-lucide="sparkles"></i>
|
|
Enrich metadata
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="content" x-show="activeView === 'settings'">
|
|
<div class="settings-page">
|
|
<form class="settings-layout" @submit.prevent="saveSettings()">
|
|
<div class="settings-column">
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>OIDC</strong>
|
|
<span>Identity provider and group mapping</span>
|
|
</div>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-field settings-wide">
|
|
<label>Callback URL</label>
|
|
<input readonly :value="callbackUrl()" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>SSO button text</span>
|
|
<span class="source-pill" :class="sourceClass('oidc_button_text')" x-text="settingSource('oidc_button_text')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.oidc_button_text" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Issuer URL</span>
|
|
<span class="source-pill" :class="sourceClass('oidc_issuer')" x-text="settingSource('oidc_issuer')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.oidc_issuer" placeholder="https://accounts.google.com" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Client ID</span>
|
|
<span class="source-pill" :class="sourceClass('oidc_client_id')" x-text="settingSource('oidc_client_id')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.oidc_client_id" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Client secret</span>
|
|
<span class="source-pill" :class="sourceClass('oidc_client_secret')" x-text="settingSource('oidc_client_secret')"></span>
|
|
</label>
|
|
<input type="password" x-model="settingsDraft.oidc_client_secret" autocomplete="off" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Admin groups</span>
|
|
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>User groups</span>
|
|
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.oidc_user_groups" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Agent</strong>
|
|
<span>AI processing directories, LLM endpoint, and execution limits</span>
|
|
</div>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-toggle">
|
|
<label>
|
|
<span>Agent enabled</span>
|
|
<span class="source-pill" :class="sourceClass('agent_enabled')" x-text="settingSource('agent_enabled')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.agent_enabled ? 'Enabled' : 'Disabled'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
|
|
</div>
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Concurrency</span>
|
|
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
|
|
</label>
|
|
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>Inbox directory</span>
|
|
<span class="source-pill" :class="sourceClass('agent_inbox_dir')" x-text="settingSource('agent_inbox_dir')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.agent_inbox_dir" />
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>Storage directory</span>
|
|
<span class="source-pill" :class="sourceClass('agent_storage_dir')" x-text="settingSource('agent_storage_dir')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.agent_storage_dir" />
|
|
</div>
|
|
<div class="setting-field settings-wide">
|
|
<label>
|
|
<span>LLM API URL</span>
|
|
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.agent_llm_url" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>LLM model</span>
|
|
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.agent_llm_model" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>LLM auth header</span>
|
|
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
|
|
</label>
|
|
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Confidence threshold</span>
|
|
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.agent_confidence_threshold" />
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>Context limit</span>
|
|
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
|
|
</label>
|
|
<input x-model="settingsDraft.agent_context_limit" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="settings-column settings-side">
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Authentication</strong>
|
|
<span>Password and SSO access switches</span>
|
|
</div>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-toggle">
|
|
<label>
|
|
<span>Password login</span>
|
|
<span class="source-pill" :class="sourceClass('auth_password_enabled')" x-text="settingSource('auth_password_enabled')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.auth_password_enabled ? 'Enabled' : 'Disabled'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.auth_password_enabled" />
|
|
</div>
|
|
</div>
|
|
<div class="setting-toggle">
|
|
<label>
|
|
<span>SSO login</span>
|
|
<span class="source-pill" :class="sourceClass('auth_sso_enabled')" x-text="settingSource('auth_sso_enabled')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.auth_sso_enabled ? 'Enabled' : 'Disabled'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.auth_sso_enabled" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>API</strong>
|
|
<span>Developer and enrichment integrations</span>
|
|
</div>
|
|
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
|
</div>
|
|
<div class="settings-grid">
|
|
<div class="setting-toggle">
|
|
<label>
|
|
<span>Swagger UI</span>
|
|
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
|
</label>
|
|
<div class="setting-toggle-row">
|
|
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
|
|
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
|
</div>
|
|
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>{{ t.settings_lastfm_api_key }}</span>
|
|
<span class="source-pill" :class="sourceClass('lastfm_api_key')" x-text="settingSource('lastfm_api_key')"></span>
|
|
</label>
|
|
<input type="password" x-model="settingsDraft.lastfm_api_key" autocomplete="off" />
|
|
<div class="setting-help">{{ t.settings_lastfm_api_key_help }}</div>
|
|
</div>
|
|
<div class="setting-field">
|
|
<label>
|
|
<span>{{ t.settings_lastfm_shared_secret }}</span>
|
|
<span class="source-pill" :class="sourceClass('lastfm_shared_secret')" x-text="settingSource('lastfm_shared_secret')"></span>
|
|
</label>
|
|
<input type="password" x-model="settingsDraft.lastfm_shared_secret" autocomplete="off" />
|
|
<div class="setting-help">{{ t.settings_lastfm_shared_secret_help }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div class="panel-title">
|
|
<strong>Agent Status</strong>
|
|
<span x-text="settingsProbeSubtitle()"></span>
|
|
</div>
|
|
<span class="badge" :class="settingsProbeBadge()" x-text="settingsProbe.status || 'idle'"></span>
|
|
</div>
|
|
<div class="probe-body">
|
|
<p class="probe-intro" x-show="settingsProbe.model_intro" x-text="settingsProbe.model_intro"></p>
|
|
<p class="probe-intro muted" x-show="!settingsProbe.model_intro" x-text="settingsProbeText()"></p>
|
|
<div class="probe-table" x-show="settingsProbe.ok">
|
|
<div class="probe-row"><span>Model</span><strong x-text="settingsProbe.model_name || 'unknown'"></strong></div>
|
|
<div class="probe-row"><span>Latency</span><strong x-text="settingsProbe.latency_ms + ' ms'"></strong></div>
|
|
<div class="probe-row"><span>Prompt tokens</span><strong x-text="settingsProbe.prompt_tokens ?? '-'"></strong></div>
|
|
<div class="probe-row"><span>Completion tokens</span><strong x-text="settingsProbe.completion_tokens ?? '-'"></strong></div>
|
|
<div class="probe-row"><span>Tokens/sec</span><strong x-text="settingsProbe.tokens_per_sec != null ? settingsProbe.tokens_per_sec.toFixed(1) : '-'"></strong></div>
|
|
</div>
|
|
<div class="toolbar" style="margin-top:14px">
|
|
<button class="btn" type="button" @click="loadSettingsProbe()" :disabled="settingsProbeLoading">
|
|
<i data-lucide="activity"></i>
|
|
Test agent
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="action-strip settings-actions">
|
|
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
|
|
<div class="toolbar">
|
|
<button class="btn" type="button" @click="loadSettings()">
|
|
<i data-lucide="refresh-cw"></i>
|
|
Reload
|
|
</button>
|
|
<button class="btn primary" type="submit" :disabled="settingsSaving">
|
|
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
|
|
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<div class="modal-backdrop" x-show="userModalOpen && activeUserDetail" x-transition @click.self="userModalOpen = false">
|
|
<section class="modal user-modal">
|
|
<div class="modal-head">
|
|
<div class="panel-title">
|
|
<strong x-text="activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'"></strong>
|
|
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''"></span>
|
|
</div>
|
|
<button class="icon-btn" @click="userModalOpen = false">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body" x-show="activeUserDetail">
|
|
<div class="segmented user-modal-tabs">
|
|
<button class="seg-btn" :class="{active: userModalTab === 'overview'}" @click="userModalTab = 'overview'">Overview</button>
|
|
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'">Activity</button>
|
|
<button class="seg-btn" :class="{active: userModalTab === 'library'}" @click="userModalTab = 'library'" disabled>Library</button>
|
|
</div>
|
|
<div x-show="userModalTab === 'overview'">
|
|
<section class="user-summary-grid">
|
|
<div class="user-summary-card">
|
|
<span>Status</span>
|
|
<strong x-text="userStatusLabel(activeUserDetail.user)"></strong>
|
|
</div>
|
|
<div class="user-summary-card">
|
|
<span>Last activity</span>
|
|
<strong x-text="userLastSeenLabel(activeUserDetail.user)"></strong>
|
|
</div>
|
|
<div class="user-summary-card">
|
|
<span>Account</span>
|
|
<strong x-text="activeUserDetail.user.is_active ? 'active' : 'disabled'"></strong>
|
|
</div>
|
|
<div class="user-summary-card">
|
|
<span>Last.fm</span>
|
|
<strong x-text="activeUserDetail.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
|
|
</div>
|
|
</section>
|
|
<section class="stats-strip user-stats-grid">
|
|
<template x-for="cell in userStatCards()" :key="cell.label">
|
|
<div class="stat-cell">
|
|
<div class="stat-value" x-text="cell.display"></div>
|
|
<div class="stat-label" x-text="cell.label"></div>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
<div class="field">
|
|
<label>Profile</label>
|
|
<div class="user-profile-facts">
|
|
<div><span>Username</span><strong x-text="activeUserDetail.user.username"></strong></div>
|
|
<div><span>Email</span><strong x-text="activeUserDetail.user.email || 'not set'"></strong></div>
|
|
<div><span>Display name</span><strong x-text="activeUserDetail.user.display_name || 'not set'"></strong></div>
|
|
<div><span>Role</span><strong x-text="activeUserDetail.user.role"></strong></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div x-show="userModalTab === 'activity'">
|
|
<div class="user-activity-list">
|
|
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
|
|
<div class="user-activity-row">
|
|
<div class="user-activity-cover">
|
|
<template x-if="play.cover_url">
|
|
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
|
</template>
|
|
<template x-if="!play.cover_url">
|
|
<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>
|
|
</template>
|
|
</div>
|
|
<div class="user-activity-main">
|
|
<div class="user-activity-title" x-text="play.title"></div>
|
|
<div class="user-activity-meta" x-text="play.artists"></div>
|
|
<div class="user-activity-sub" x-text="userPlayMeta(play)"></div>
|
|
</div>
|
|
<div class="user-activity-time">
|
|
<strong x-text="userPlayListened(play)"></strong>
|
|
<span x-text="shortDate(play.played_at)"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="empty" x-show="activeUserDetail && (!activeUserDetail.recent_plays || activeUserDetail.recent_plays.length === 0)">No play history for this user yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
|
<section class="modal">
|
|
<div class="modal-head">
|
|
<div class="panel-title">
|
|
<strong x-text="activeReview ? 'Review #' + activeReview.id : 'Review'"></strong>
|
|
<span x-text="activeReview?.filename || activeReview?.review_type || ''"></span>
|
|
</div>
|
|
<button class="icon-btn" @click="reviewModalOpen = false">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body" x-show="activeReview">
|
|
<div class="field">
|
|
<label>Input</label>
|
|
<textarea readonly x-text="activeReview?.input_path || ''"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Status</label>
|
|
<span class="badge" :class="activeReview?.status" x-text="activeReview?.status"></span>
|
|
</div>
|
|
<div class="field">
|
|
<label>Agent</label>
|
|
<input readonly :value="activeReview?.model_name || 'not processed yet'" />
|
|
</div>
|
|
<div class="field" x-show="activeReview?.error_message">
|
|
<label>Error</label>
|
|
<textarea readonly x-text="activeReview?.error_message || ''"></textarea>
|
|
</div>
|
|
<div x-show="activeReview?.status === 'pending'">
|
|
<div class="field">
|
|
<label>Artist</label>
|
|
<input x-model="reviewDraft.artist" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Album</label>
|
|
<input x-model="reviewDraft.album" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Title</label>
|
|
<input x-model="reviewDraft.title" />
|
|
</div>
|
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
<div class="field">
|
|
<label>Year</label>
|
|
<input type="number" min="0" max="3000" x-model="reviewDraft.year" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Track</label>
|
|
<input type="number" min="0" x-model="reviewDraft.track_number" />
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Genre</label>
|
|
<input x-model="reviewDraft.genre" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Featured artists</label>
|
|
<input x-model="reviewDraft.featured_artists" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Release type</label>
|
|
<select x-model="reviewDraft.release_type">
|
|
<option value="album">Album</option>
|
|
<option value="single">Single</option>
|
|
<option value="ep">EP</option>
|
|
<option value="compilation">Compilation</option>
|
|
<option value="soundtrack">Soundtrack</option>
|
|
<option value="live">Live</option>
|
|
<option value="remix">Remix</option>
|
|
<option value="unknown">Unknown</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Notes</label>
|
|
<textarea x-model="reviewDraft.notes"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<button class="btn primary" x-show="activeReview?.status === 'pending'" @click="approveActiveReview()">
|
|
<i data-lucide="check"></i>
|
|
Approve
|
|
</button>
|
|
<button class="btn warn" @click="bulkOneReview('requeue', activeReview)">
|
|
<i data-lucide="rotate-ccw"></i>
|
|
Requeue
|
|
</button>
|
|
<button class="btn danger" @click="bulkOneReview('delete', activeReview)">
|
|
<i data-lucide="trash-2"></i>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="modal-backdrop" x-show="editorOpen && activeLibraryItem" x-transition @click.self="editorOpen = false">
|
|
<section class="modal">
|
|
<div class="modal-head">
|
|
<div class="panel-title">
|
|
<strong x-text="editorTitle()"></strong>
|
|
<span x-text="editorSubtitle()"></span>
|
|
</div>
|
|
<button class="icon-btn" @click="editorOpen = false">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body" x-show="activeLibraryItem">
|
|
<div class="empty" x-show="editorLoading">Loading editor...</div>
|
|
<div x-show="!editorLoading">
|
|
<div class="field">
|
|
<label x-text="isArtistEditor() ? 'Artist name' : (isReleaseEditor() ? 'Release title' : (isTrackEditor() ? 'Track title' : 'Title'))"></label>
|
|
<input x-model="editorDraft.title" />
|
|
</div>
|
|
|
|
<div class="editor-grid" x-show="isReleaseEditor()">
|
|
<div class="field">
|
|
<label>Release type</label>
|
|
<select x-model="editorDraft.release_type">
|
|
<option value="album">Album</option>
|
|
<option value="single">Single</option>
|
|
<option value="ep">EP</option>
|
|
<option value="compilation">Compilation</option>
|
|
<option value="soundtrack">Soundtrack</option>
|
|
<option value="live">Live</option>
|
|
<option value="remix">Remix</option>
|
|
<option value="unknown">Unknown</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Year</label>
|
|
<input type="number" min="0" max="3000" x-model="editorDraft.year" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field" x-show="isReleaseEditor()">
|
|
<label>Release tracks</label>
|
|
<div class="release-track-search-row">
|
|
<div class="artist-picker">
|
|
<input class="search" placeholder="Search track" x-model="releaseTrackSearch" @input.debounce.300ms="searchReleaseTracks()" @keydown.enter.prevent="addBestReleaseTrack()" @keydown.escape="clearReleaseTrackSearch()" />
|
|
<div class="artist-results" x-show="releaseTrackSearchOpen()" x-transition>
|
|
<template x-for="track in availableReleaseTrackResults()" :key="track.id">
|
|
<button class="artist-result" type="button" @click="addReleaseTrack(track)">
|
|
<span x-text="track.title"></span>
|
|
<small x-text="releaseTrackSearchMeta(track)"></small>
|
|
</button>
|
|
</template>
|
|
<div class="artist-result muted" x-show="releaseTrackSearchLoading">Searching...</div>
|
|
<div class="artist-result muted" x-show="!releaseTrackSearchLoading && availableReleaseTrackResults().length === 0">No matching tracks</div>
|
|
</div>
|
|
</div>
|
|
<button class="btn" type="button" @click="addBestReleaseTrack()" :disabled="!releaseTrackSearch.trim()">
|
|
<i data-lucide="plus"></i>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div class="release-track-list" x-show="releaseTracks().length">
|
|
<div class="release-track-head">
|
|
<span>Disc</span>
|
|
<span>Track #</span>
|
|
<span>Title</span>
|
|
<span>Artists</span>
|
|
<span>Current release</span>
|
|
<span>Time</span>
|
|
<span></span>
|
|
</div>
|
|
<template x-for="track in releaseTracks()" :key="track.id">
|
|
<div class="release-track-row">
|
|
<input type="number" min="1" max="999" x-model="track.disc_number" />
|
|
<input type="number" min="1" max="9999" x-model="track.track_number" />
|
|
<div class="release-track-title" x-text="track.title"></div>
|
|
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
|
|
<div class="release-track-meta" x-text="releaseTrackOrigin(track)"></div>
|
|
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
|
|
<button class="icon-btn" type="button" @click="removeReleaseTrack(track.id)" title="Remove from release">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty" x-show="!releaseTracks().length">No tracks attached</div>
|
|
</div>
|
|
|
|
<div class="editor-grid" x-show="isTrackEditor()">
|
|
<div class="field">
|
|
<label>Track #</label>
|
|
<input type="number" min="1" max="9999" x-model="editorDraft.track_number" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Disc #</label>
|
|
<input type="number" min="1" max="999" x-model="editorDraft.disc_number" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Year</label>
|
|
<input type="number" min="0" max="3000" x-model="editorDraft.year" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field" x-show="isTrackEditor()">
|
|
<label>Release</label>
|
|
<div class="artist-tags">
|
|
<span class="tag relation" x-show="selectedEditorRelease()" x-text="selectedEditorReleaseLabel()"></span>
|
|
<span class="muted" x-show="!selectedEditorRelease()">No release selected</span>
|
|
</div>
|
|
<div class="artist-picker">
|
|
<input class="search" placeholder="Search release to move track" x-model="editorReleaseToAdd" @keydown.enter.prevent="selectEditorRelease()" @keydown.escape="editorReleaseToAdd = ''" />
|
|
<div class="artist-results" x-show="editorReleaseSearchOpen()" x-transition>
|
|
<template x-for="release in filteredEditorReleases()" :key="release.id">
|
|
<button class="artist-result" type="button" @click="selectEditorRelease(release)">
|
|
<span x-text="release.title"></span>
|
|
<small x-text="release.subtitle"></small>
|
|
</button>
|
|
</template>
|
|
<div class="artist-result muted" x-show="filteredEditorReleases().length === 0">No matching releases</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field" x-show="isReleaseEditor() || isTrackEditor()">
|
|
<label x-text="isTrackEditor() ? 'Track artists' : 'Release artists'"></label>
|
|
<div class="artist-tags">
|
|
<template x-for="artist in selectedEditorArtists()" :key="artist.id">
|
|
<span class="tag relation">
|
|
<span x-text="artist.name"></span>
|
|
<button type="button" @click="removeEditorArtist(artist.id)">x</button>
|
|
</span>
|
|
</template>
|
|
<span class="muted" x-show="selectedEditorArtists().length === 0">No artists attached</span>
|
|
</div>
|
|
<div class="artist-picker">
|
|
<input class="search" placeholder="Search artist" x-model="editorArtistToAdd" @keydown.enter.prevent="addEditorArtist()" @keydown.escape="editorArtistToAdd = ''" />
|
|
<div class="artist-results" x-show="editorArtistSearchOpen()" x-transition>
|
|
<template x-for="artist in filteredEditorArtists()" :key="artist.id">
|
|
<button class="artist-result" type="button" @click="addEditorArtist(artist)" x-text="artist.name"></button>
|
|
</template>
|
|
<div class="artist-result muted" x-show="filteredEditorArtists().length === 0">No matching artists</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field" x-show="canShowMetadataTags()">
|
|
<label>Metadata genres and tags</label>
|
|
<div class="metadata-tags" x-show="metadataTags().length">
|
|
<template x-for="tag in metadataTags()" :key="`${tag.source}:${tag.name}`">
|
|
<span class="tag" :class="metadataTagClass(tag)" :title="metadataTagTitle(tag)">
|
|
<span x-text="tag.name"></span>
|
|
<small x-text="metadataTagSourceLabel(tag)"></small>
|
|
<small x-show="metadataTagScore(tag)" x-text="metadataTagScore(tag)"></small>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div class="metadata-empty muted" x-show="!metadataTags().length">
|
|
No metadata genres or tags saved yet
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Visibility</label>
|
|
<select class="search" style="width:100%" x-model="editorDraft.hidden">
|
|
<option value="false">Visible in player</option>
|
|
<option value="true">Hidden from player</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div x-show="canEditLibraryImage()">
|
|
<label class="muted" style="display:block; margin: 2px 0 8px; font-size: 11px; font-weight: 800; text-transform: uppercase;">Image</label>
|
|
<div class="image-editor">
|
|
<div class="image-preview">
|
|
<template x-if="editorDetail && editorDetail.current_image_url">
|
|
<img :src="editorDetail.current_image_url" alt="" />
|
|
</template>
|
|
<span x-show="!editorDetail || !editorDetail.current_image_url">No image</span>
|
|
</div>
|
|
<div>
|
|
<div class="image-actions">
|
|
<input type="file" accept="image/*" x-ref="libraryImageInput" style="display:none" @change="setEditorImageFile($event)" />
|
|
<button class="btn" type="button" @click="$refs.libraryImageInput.click()">
|
|
<i data-lucide="image-plus"></i>
|
|
Choose image
|
|
</button>
|
|
<button class="btn primary" type="button" @click="uploadLibraryImage()" :disabled="!editorImageFile || editorImageUploading">
|
|
<i :data-lucide="editorImageUploading ? 'loader-circle' : 'upload'"></i>
|
|
<span x-text="editorImageUploading ? 'Uploading...' : 'Upload'"></span>
|
|
</button>
|
|
<button class="btn danger" type="button" @click="removeLibraryImage()" :disabled="!editorDetail || !editorDetail.current_image_url || editorImageUploading">
|
|
<i data-lucide="trash-2"></i>
|
|
Remove
|
|
</button>
|
|
<span class="file-name" x-show="editorImageFile" x-text="editorImageFile ? editorImageFile.name : ''"></span>
|
|
</div>
|
|
<p class="muted" x-show="isReleaseEditor()">Release covers are uploaded manually here.</p>
|
|
<p class="muted" x-show="isArtistEditor()">Pick an image from releases where this artist appears, or upload one manually.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="isArtistEditor()">
|
|
<div class="field" style="margin-bottom:8px">
|
|
<label>Available release images</label>
|
|
</div>
|
|
<div class="cover-grid" x-show="editorDetail && editorDetail.available_covers && editorDetail.available_covers.length">
|
|
<template x-for="cover in editorDetail.available_covers" :key="cover.media_file_id">
|
|
<button class="cover-option" type="button" :class="{active: editorDetail.current_image_url && editorDetail.current_image_url.includes('/cover/' + cover.media_file_id + '/')}" @click="setLibraryImage(cover.media_file_id)">
|
|
<img :src="cover.cover_url" alt="" />
|
|
<span x-text="cover.release_title"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
<div class="empty" x-show="editorDetail && (!editorDetail.available_covers || editorDetail.available_covers.length === 0)">No release images found for this artist</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
|
|
<i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i>
|
|
<span x-text="editorSaving ? 'Saving...' : (editorIsNewRelease() ? 'Create' : 'Save')"></span>
|
|
</button>
|
|
<button class="btn danger" x-show="!editorIsNewRelease()" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
|
|
<i data-lucide="trash-2"></i>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="toast" x-show="toastMessage" x-transition x-text="toastMessage"></div>
|
|
<div class="loading-mask" x-show="loading">
|
|
<span class="badge running">Loading</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
<script>
|
|
function adminV2() {
|
|
return {
|
|
apiBase: '/admin/v2/api',
|
|
activeView: 'reviews',
|
|
loading: true,
|
|
libraryLoading: false,
|
|
toastMessage: '',
|
|
stats: {},
|
|
runtime: { agent: {}, storage: [], node: {} },
|
|
libraryOverview: {},
|
|
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
|
|
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
|
|
usersLoading: false,
|
|
userSearch: '',
|
|
userModalOpen: false,
|
|
userModalTab: 'overview',
|
|
activeUserDetail: null,
|
|
reviewFilter: { status: null, search: '' },
|
|
selectedReviewIds: {},
|
|
reviewSelectionScope: 'ids',
|
|
activeReview: null,
|
|
reviewDraft: {
|
|
title: '',
|
|
artist: '',
|
|
album: '',
|
|
year: '',
|
|
track_number: '',
|
|
genre: '',
|
|
featured_artists: '',
|
|
release_type: 'album',
|
|
notes: ''
|
|
},
|
|
reviewModalOpen: false,
|
|
reviewStatuses: [
|
|
{ value: null, label: 'All' },
|
|
{ value: 'queued', label: 'Queued' },
|
|
{ value: 'processing', label: 'Processing' },
|
|
{ value: 'pending', label: 'Pending' },
|
|
{ value: 'failed', label: 'Failed' }
|
|
],
|
|
jobs: [],
|
|
recentRuns: [],
|
|
activeJobRuns: [],
|
|
activeJobRunsName: null,
|
|
activeJobName: null,
|
|
activeRunDetail: null,
|
|
recentRunsPage: 0,
|
|
recentRunsPerPage: 8,
|
|
runLogAutoScroll: true,
|
|
runLogRefreshing: false,
|
|
metadataBackfillOptions: {
|
|
audio_bitrate: true,
|
|
audio_sample_rate: true,
|
|
audio_bit_depth: true,
|
|
duration_seconds: true,
|
|
local_genres: true,
|
|
lastfm_tags: true,
|
|
musicbrainz_tags: true,
|
|
mode: 'fill_missing'
|
|
},
|
|
artworkBackfillOptions: {
|
|
mode: 'missing'
|
|
},
|
|
libraryKind: 'artists',
|
|
librarySearch: '',
|
|
library: { items: [], total: 0, limit: 40, offset: 0 },
|
|
selectedLibraryIds: {},
|
|
librarySelectionScope: 'ids',
|
|
activeLibraryItem: null,
|
|
editorOpen: false,
|
|
editorLoading: false,
|
|
editorSaving: false,
|
|
editorImageUploading: false,
|
|
editorImageFile: null,
|
|
editorArtistToAdd: '',
|
|
editorReleaseToAdd: '',
|
|
releaseTrackSearch: '',
|
|
releaseTrackSearchResults: [],
|
|
releaseTrackSearchLoading: false,
|
|
releaseTrackSearchToken: 0,
|
|
editorDetail: null,
|
|
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
|
|
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
|
settingsDraft: {
|
|
auth_password_enabled: false,
|
|
auth_sso_enabled: false,
|
|
oidc_button_text: '',
|
|
oidc_issuer: '',
|
|
oidc_client_id: '',
|
|
oidc_client_secret: '',
|
|
oidc_admin_groups: '',
|
|
oidc_user_groups: '',
|
|
swagger_enabled: false,
|
|
lastfm_api_key: '',
|
|
lastfm_shared_secret: '',
|
|
agent_enabled: false,
|
|
agent_inbox_dir: '',
|
|
agent_storage_dir: '',
|
|
agent_llm_url: '',
|
|
agent_llm_model: '',
|
|
agent_llm_auth: '',
|
|
agent_confidence_threshold: '',
|
|
agent_context_limit: '',
|
|
agent_concurrency: ''
|
|
},
|
|
settingsProbe: { status: 'idle', ok: false },
|
|
settingsProbeLoading: false,
|
|
settingsSaving: false,
|
|
routeReady: false,
|
|
poller: null,
|
|
|
|
async init() {
|
|
this.applyRouteFromHash();
|
|
await this.refreshAll();
|
|
this.routeReady = true;
|
|
this.activateCurrentView(false);
|
|
window.addEventListener('hashchange', () => {
|
|
this.applyRouteFromHash();
|
|
this.activateCurrentView(false);
|
|
});
|
|
this.poller = setInterval(() => this.poll(), 4000);
|
|
this.icons();
|
|
},
|
|
|
|
async request(url, options = {}) {
|
|
const headers = Object.assign({ 'Accept': 'application/json' }, options.headers || {});
|
|
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
|
const response = await fetch(url, Object.assign({ credentials: 'same-origin', headers }, options));
|
|
if (!response.ok) {
|
|
let message = response.statusText;
|
|
try {
|
|
const body = await response.json();
|
|
message = body.error || message;
|
|
} catch (_) {}
|
|
throw new Error(message);
|
|
}
|
|
return response.json();
|
|
},
|
|
|
|
async refreshAll() {
|
|
this.loading = true;
|
|
try {
|
|
const data = await this.request(`${this.apiBase}/dashboard`);
|
|
this.stats = data.stats || {};
|
|
this.runtime = data.runtime || this.runtime;
|
|
this.libraryOverview = data.library || {};
|
|
this.reviews = data.reviews || this.reviews;
|
|
this.jobs = data.jobs || [];
|
|
this.recentRuns = data.recent_runs || [];
|
|
if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name;
|
|
await this.loadSettings(false);
|
|
await this.loadLibrary(false);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.loading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async poll() {
|
|
if (this.loading) return;
|
|
if (this.activeView === 'jobs') {
|
|
await Promise.allSettled([
|
|
this.loadJobs(false),
|
|
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
|
|
this.refreshActiveRunDetail()
|
|
]);
|
|
} else if (this.activeView === 'users') {
|
|
await Promise.allSettled([this.loadUsers(false)]);
|
|
} else {
|
|
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
|
}
|
|
},
|
|
|
|
applyRouteFromHash() {
|
|
const raw = (window.location.hash || '#reviews').replace(/^#\/?/, '');
|
|
const parts = raw.split('/').filter(Boolean);
|
|
const view = parts[0] || 'reviews';
|
|
if (view === 'reviews') {
|
|
const nextStatus = parts[1] || null;
|
|
if (this.reviewFilter.status !== nextStatus) this.clearReviewSelection();
|
|
this.activeView = 'reviews';
|
|
this.reviewFilter.status = nextStatus;
|
|
} else if (view === 'jobs') {
|
|
this.activeView = 'jobs';
|
|
if (parts[1]) {
|
|
const nextJobName = decodeURIComponent(parts[1]);
|
|
if (this.activeJobName !== nextJobName) {
|
|
this.activeJobRuns = [];
|
|
this.activeJobRunsName = nextJobName;
|
|
this.recentRunsPage = 0;
|
|
}
|
|
this.activeJobName = nextJobName;
|
|
}
|
|
} else if (view === 'library') {
|
|
const nextKind = ['artists', 'releases', 'tracks', 'playlists'].includes(parts[1]) ? parts[1] : 'artists';
|
|
if (this.libraryKind !== nextKind) this.clearLibrarySelection();
|
|
this.activeView = 'library';
|
|
this.libraryKind = nextKind;
|
|
} else if (view === 'settings') {
|
|
this.activeView = 'settings';
|
|
} else if (view === 'users') {
|
|
this.activeView = 'users';
|
|
} else if (view === 'tools') {
|
|
this.activeView = 'tools';
|
|
} else {
|
|
this.activeView = 'reviews';
|
|
}
|
|
},
|
|
|
|
setRoute(path) {
|
|
if (!path.startsWith('#')) path = '#' + path;
|
|
if (window.location.hash !== path) {
|
|
window.history.pushState(null, '', path);
|
|
}
|
|
},
|
|
|
|
async activateCurrentView(updateRoute = true) {
|
|
if (this.activeView === 'reviews') {
|
|
if (updateRoute) this.setRoute(this.reviewFilter.status ? `#reviews/${this.reviewFilter.status}` : '#reviews');
|
|
await this.loadReviews(false);
|
|
} else if (this.activeView === 'jobs') {
|
|
if (updateRoute) this.setRoute(this.activeJobName ? `#jobs/${encodeURIComponent(this.activeJobName)}` : '#jobs');
|
|
await this.loadJobs();
|
|
if (this.activeJobName) await this.loadRunsForJob(this.activeJobName);
|
|
} else if (this.activeView === 'library') {
|
|
if (updateRoute) this.setRoute(`#library/${this.libraryKind}`);
|
|
await this.loadLibrary(false);
|
|
} else if (this.activeView === 'settings') {
|
|
if (updateRoute) this.setRoute('#settings');
|
|
await this.loadSettings();
|
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
|
await this.loadSettingsProbe(false);
|
|
}
|
|
} else if (this.activeView === 'users') {
|
|
if (updateRoute) this.setRoute('#users');
|
|
await this.loadUsers(false);
|
|
} else if (this.activeView === 'tools' && updateRoute) {
|
|
this.setRoute('#tools');
|
|
}
|
|
},
|
|
|
|
openReviews(status = null) {
|
|
if (this.reviewFilter.status !== status) this.clearReviewSelection();
|
|
this.activeView = 'reviews';
|
|
this.reviewFilter.status = status;
|
|
this.setRoute(status ? `#reviews/${status}` : '#reviews');
|
|
this.loadReviews();
|
|
},
|
|
|
|
openJobs(name = this.activeJobName) {
|
|
this.activeView = 'jobs';
|
|
if (name && this.activeJobName !== name) {
|
|
this.activeJobRuns = [];
|
|
this.activeJobRunsName = name;
|
|
this.recentRunsPage = 0;
|
|
this.activeJobName = name;
|
|
} else if (name) {
|
|
this.activeJobName = name;
|
|
}
|
|
this.setRoute(this.activeJobName ? `#jobs/${encodeURIComponent(this.activeJobName)}` : '#jobs');
|
|
this.loadJobs();
|
|
if (this.activeJobName) this.loadRunsForJob(this.activeJobName);
|
|
},
|
|
|
|
openLibrary(kind = this.libraryKind) {
|
|
this.activeView = 'library';
|
|
const nextKind = ['artists', 'releases', 'tracks', 'playlists'].includes(kind) ? kind : 'artists';
|
|
if (this.libraryKind !== nextKind) this.clearLibrarySelection();
|
|
this.libraryKind = nextKind;
|
|
this.setRoute(`#library/${this.libraryKind}`);
|
|
this.loadLibrary();
|
|
},
|
|
|
|
openTools() {
|
|
this.activeView = 'tools';
|
|
this.setRoute('#tools');
|
|
},
|
|
|
|
openUsers() {
|
|
this.activeView = 'users';
|
|
this.setRoute('#users');
|
|
this.loadUsers();
|
|
},
|
|
|
|
async loadReviews(resetOffset = true) {
|
|
if (resetOffset) this.reviews.offset = 0;
|
|
const params = new URLSearchParams();
|
|
if (this.reviewFilter.status) params.set('status', this.reviewFilter.status);
|
|
if (this.reviewFilter.search) params.set('search', this.reviewFilter.search);
|
|
params.set('limit', this.reviews.limit || 80);
|
|
params.set('offset', this.reviews.offset || 0);
|
|
try {
|
|
this.reviews = await this.request(`${this.apiBase}/reviews?${params.toString()}`);
|
|
if (resetOffset) this.clearReviewSelection();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async loadJobs(showErrors = true) {
|
|
try {
|
|
this.jobs = await this.request(`${this.apiBase}/jobs`);
|
|
if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name;
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
} finally {
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async loadLibrary(resetOffset = true) {
|
|
if (this.activeView !== 'library' && resetOffset) return;
|
|
if (resetOffset) this.library.offset = 0;
|
|
this.libraryLoading = true;
|
|
const params = new URLSearchParams();
|
|
params.set('kind', this.libraryKind);
|
|
params.set('limit', this.library.limit || 40);
|
|
params.set('offset', this.library.offset || 0);
|
|
if (this.librarySearch) params.set('search', this.librarySearch);
|
|
try {
|
|
this.library = await this.request(`${this.apiBase}/library?${params.toString()}`);
|
|
if (resetOffset) this.clearLibrarySelection();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.libraryLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async loadUsers(resetOffset = true) {
|
|
if (this.activeView !== 'users' && resetOffset) return;
|
|
if (resetOffset) this.users.offset = 0;
|
|
this.usersLoading = true;
|
|
const params = new URLSearchParams();
|
|
params.set('limit', this.users.limit || 40);
|
|
params.set('offset', this.users.offset || 0);
|
|
if (this.userSearch) params.set('search', this.userSearch);
|
|
try {
|
|
this.users = await this.request(`${this.apiBase}/users?${params.toString()}`);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.usersLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async openUser(user) {
|
|
if (!user) return;
|
|
this.userModalTab = 'overview';
|
|
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
|
|
this.userModalOpen = true;
|
|
try {
|
|
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async loadSettings(showErrors = true) {
|
|
try {
|
|
this.settings = await this.request(`${this.apiBase}/settings`);
|
|
this.settingsDraft = Object.assign({}, this.settingsDraft, this.settings.values || {});
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
} finally {
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async saveSettings() {
|
|
if (this.settingsSaving) return;
|
|
this.settingsSaving = true;
|
|
try {
|
|
await this.request(`${this.apiBase}/settings`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.settingsDraft)
|
|
});
|
|
await this.loadSettings(false);
|
|
this.showToast('Settings saved');
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.settingsSaving = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async openSettings() {
|
|
this.activeView = 'settings';
|
|
this.setRoute('#settings');
|
|
await this.loadSettings();
|
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
|
await this.loadSettingsProbe(false);
|
|
}
|
|
},
|
|
|
|
async loadSettingsProbe(showErrors = true) {
|
|
this.settingsProbeLoading = true;
|
|
try {
|
|
this.settingsProbe = await this.request(`${this.apiBase}/settings/probe`);
|
|
} catch (error) {
|
|
this.settingsProbe = { status: 'error', ok: false, error: error.message };
|
|
if (showErrors) this.showToast(error.message);
|
|
} finally {
|
|
this.settingsProbeLoading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
settingSource(key) {
|
|
return (this.settings.sources || {})[key] || 'default';
|
|
},
|
|
|
|
sourceClass(key) {
|
|
return this.settingSource(key);
|
|
},
|
|
|
|
callbackUrl() {
|
|
return `${window.location.origin}/auth/oidc/callback`;
|
|
},
|
|
|
|
settingsProbeBadge() {
|
|
if (this.settingsProbeLoading) return 'running';
|
|
if (this.settingsProbe.status === 'ok') return 'ok';
|
|
if (this.settingsProbe.status === 'error') return 'failed';
|
|
return 'disabled';
|
|
},
|
|
|
|
settingsProbeSubtitle() {
|
|
if (this.settingsProbeLoading) return 'Checking LLM connection';
|
|
if (this.settingsProbe.status === 'ok') return 'LLM connection OK';
|
|
if (this.settingsProbe.status === 'error') return 'LLM connection error';
|
|
if (this.settingsProbe.status === 'disabled') return 'Agent is disabled';
|
|
if (this.settingsProbe.status === 'not_configured') return 'LLM URL is not configured';
|
|
return 'Connection probe';
|
|
},
|
|
|
|
settingsProbeText() {
|
|
if (this.settingsProbeLoading) return 'Checking connection...';
|
|
if (this.settingsProbe.error) return this.settingsProbe.error;
|
|
return this.settingsProbeSubtitle();
|
|
},
|
|
|
|
setReviewStatus(status) {
|
|
this.openReviews(status);
|
|
},
|
|
|
|
openReview(row) {
|
|
this.activeReview = row;
|
|
this.reviewDraft = Object.assign({
|
|
title: '',
|
|
artist: '',
|
|
album: '',
|
|
year: '',
|
|
track_number: '',
|
|
genre: '',
|
|
featured_artists: '',
|
|
release_type: 'album',
|
|
notes: ''
|
|
}, row.normalized || {});
|
|
this.activeRunDetail = null;
|
|
this.reviewModalOpen = true;
|
|
},
|
|
|
|
toggleReview(row) {
|
|
this.reviewSelectionScope = 'ids';
|
|
if (this.selectedReviewIds[row.id]) {
|
|
delete this.selectedReviewIds[row.id];
|
|
} else {
|
|
this.selectedReviewIds[row.id] = row.status;
|
|
}
|
|
this.selectedReviewIds = Object.assign({}, this.selectedReviewIds);
|
|
},
|
|
|
|
isReviewSelected(id) {
|
|
return this.reviewSelectionScope === 'filter' || Boolean(this.selectedReviewIds[id]);
|
|
},
|
|
|
|
selectVisibleReviews() {
|
|
this.reviewSelectionScope = 'ids';
|
|
const selected = {};
|
|
for (const row of this.reviews.items) selected[row.id] = row.status;
|
|
this.selectedReviewIds = selected;
|
|
},
|
|
|
|
selectReviewFilter() {
|
|
this.reviewSelectionScope = 'filter';
|
|
this.selectedReviewIds = {};
|
|
},
|
|
|
|
clearReviewSelection() {
|
|
this.reviewSelectionScope = 'ids';
|
|
this.selectedReviewIds = {};
|
|
},
|
|
|
|
selectedReviewCount() {
|
|
if (this.reviewSelectionScope === 'filter') return this.reviews.total || 0;
|
|
return Object.keys(this.selectedReviewIds).length;
|
|
},
|
|
|
|
reviewSelectionSummary() {
|
|
const total = this.selectedReviewCount();
|
|
if (!total) return 'Nothing selected';
|
|
const counts = this.reviewSelectionScope === 'filter'
|
|
? this.filterStatusCounts()
|
|
: Object.values(this.selectedReviewIds).reduce((acc, status) => {
|
|
acc[status] = (acc[status] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
const parts = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status}: ${count}`);
|
|
return `${total} selected · ${parts.join(' · ')}`;
|
|
},
|
|
|
|
filterStatusCounts() {
|
|
if (this.reviewFilter.status) return { [this.reviewFilter.status]: this.reviews.total || 0 };
|
|
return (this.reviews.status_counts || []).reduce((acc, row) => {
|
|
acc[row.status] = row.count;
|
|
return acc;
|
|
}, {});
|
|
},
|
|
|
|
async bulkReviews(action) {
|
|
const count = this.selectedReviewCount();
|
|
if (!count) return;
|
|
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
|
|
const payload = {
|
|
action,
|
|
mode: this.reviewSelectionScope,
|
|
ids: Object.keys(this.selectedReviewIds).map(Number),
|
|
filter: {
|
|
status: this.reviewFilter.status,
|
|
search: this.reviewFilter.search || null
|
|
}
|
|
};
|
|
try {
|
|
const result = await this.request(`${this.apiBase}/reviews/bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
this.showToast(`${result.affected} review row(s) updated`);
|
|
this.clearReviewSelection();
|
|
await this.loadReviews(false);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async bulkOneReview(action, row) {
|
|
this.reviewSelectionScope = 'ids';
|
|
this.selectedReviewIds = { [row.id]: row.status };
|
|
await this.bulkReviews(action);
|
|
this.reviewModalOpen = false;
|
|
},
|
|
|
|
async approveActiveReview() {
|
|
if (!this.activeReview) return;
|
|
try {
|
|
await this.request(`${this.apiBase}/reviews/${this.activeReview.id}/approve`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.reviewDraft)
|
|
});
|
|
this.showToast('Review approved');
|
|
this.reviewModalOpen = false;
|
|
this.activeReview = null;
|
|
await this.loadReviews(false);
|
|
await this.loadLibrary(false);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async runJob(job) {
|
|
if (!job) return;
|
|
if (job.launching) return;
|
|
job.launching = true;
|
|
try {
|
|
const result = this.isMetadataBackfillJob(job)
|
|
? await this.request(`${this.apiBase}/jobs/metadata_backfill/run-options`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.metadataBackfillPayload())
|
|
})
|
|
: this.isArtworkBackfillJob(job)
|
|
? await this.request(`${this.apiBase}/jobs/artwork_backfill/run-options`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.artworkBackfillPayload())
|
|
})
|
|
: await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/run`, { method: 'POST' });
|
|
this.showToast(`Run #${result.run_id} started`);
|
|
this.activeJobName = job.name;
|
|
this.recentRunsPage = 0;
|
|
await this.loadJobs(false);
|
|
await this.loadRunsForJob(job.name);
|
|
await this.loadRunDetail({ id: result.run_id, job_name: job.name }, { silent: true });
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
job.launching = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
isMetadataBackfillJob(job) {
|
|
return job && job.name === 'metadata_backfill';
|
|
},
|
|
|
|
isArtworkBackfillJob(job) {
|
|
return job && job.name === 'artwork_backfill';
|
|
},
|
|
|
|
jobHasParameterForm(job) {
|
|
return this.isMetadataBackfillJob(job) || this.isArtworkBackfillJob(job);
|
|
},
|
|
|
|
metadataBackfillPayload() {
|
|
const options = this.metadataBackfillOptions || {};
|
|
return {
|
|
audio_bitrate: Boolean(options.audio_bitrate),
|
|
audio_sample_rate: Boolean(options.audio_sample_rate),
|
|
audio_bit_depth: Boolean(options.audio_bit_depth),
|
|
duration_seconds: Boolean(options.duration_seconds),
|
|
local_genres: Boolean(options.local_genres),
|
|
lastfm_tags: Boolean(options.lastfm_tags),
|
|
musicbrainz_tags: Boolean(options.musicbrainz_tags),
|
|
overwrite: options.mode === 'overwrite'
|
|
};
|
|
},
|
|
|
|
artworkBackfillPayload() {
|
|
const options = this.artworkBackfillOptions || {};
|
|
return {
|
|
overwrite_existing: options.mode === 'overwrite'
|
|
};
|
|
},
|
|
|
|
async toggleJob(job) {
|
|
try {
|
|
const result = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/toggle`, { method: 'POST' });
|
|
job.enabled = result.enabled;
|
|
await this.loadJobs(false);
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async selectJob(name) {
|
|
this.activeJobName = name;
|
|
this.setRoute(`#jobs/${encodeURIComponent(name)}`);
|
|
this.activeReview = null;
|
|
this.activeRunDetail = null;
|
|
this.activeJobRuns = [];
|
|
this.activeJobRunsName = name;
|
|
this.recentRunsPage = 0;
|
|
this.runLogAutoScroll = true;
|
|
await this.loadRunsForJob(name);
|
|
},
|
|
|
|
async loadRunsForJob(name, showErrors = true) {
|
|
try {
|
|
const data = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(name)}/runs`);
|
|
const runs = data.runs || [];
|
|
const job = this.jobs.find(item => item.name === name);
|
|
if (job) job.recent_runs = runs;
|
|
if (this.activeJobName === name) {
|
|
this.activeJobRuns = runs;
|
|
this.activeJobRunsName = name;
|
|
const maxPage = Math.max(0, Math.ceil(runs.length / this.recentRunsPerPage) - 1);
|
|
this.recentRunsPage = Math.min(this.recentRunsPage, maxPage);
|
|
}
|
|
} catch (error) {
|
|
if (showErrors) this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async loadRunDetail(run, options = {}) {
|
|
if (!run) return;
|
|
this.activeReview = null;
|
|
const previousJobName = this.activeJobName;
|
|
const preserveScroll = Boolean(options.preserveScroll);
|
|
const sameRun = this.activeRunDetail && Number(this.activeRunDetail.run.id) === Number(run.id);
|
|
const shouldFollow = !preserveScroll || !sameRun || this.runLogAutoScroll || this.isRunLogAtBottom();
|
|
try {
|
|
this.activeRunDetail = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(run.job_name)}/runs/${run.id}`);
|
|
this.activeJobName = run.job_name;
|
|
if (previousJobName !== run.job_name) this.recentRunsPage = 0;
|
|
if (!preserveScroll || !sameRun) this.runLogAutoScroll = true;
|
|
this.$nextTick(() => {
|
|
if (shouldFollow) this.scrollRunLogToBottom(true);
|
|
});
|
|
} catch (error) {
|
|
if (!options.silent) this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async refreshActiveRunDetail() {
|
|
if (this.runLogRefreshing || !this.activeRunDetail || this.activeView !== 'jobs') return;
|
|
this.runLogRefreshing = true;
|
|
try {
|
|
await this.loadRunDetail(this.activeRunDetail.run, { preserveScroll: true, silent: true });
|
|
} finally {
|
|
this.runLogRefreshing = false;
|
|
}
|
|
},
|
|
|
|
handleRunLogScroll() {
|
|
this.runLogAutoScroll = this.isRunLogAtBottom();
|
|
},
|
|
|
|
isRunLogAtBottom() {
|
|
const el = this.$refs.runLogOutput;
|
|
if (!el) return true;
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 28;
|
|
},
|
|
|
|
scrollRunLogToBottom(force = false) {
|
|
const el = this.$refs.runLogOutput;
|
|
if (!el) return;
|
|
if (force || this.runLogAutoScroll) {
|
|
el.scrollTop = el.scrollHeight;
|
|
this.runLogAutoScroll = true;
|
|
}
|
|
},
|
|
|
|
enableRunLogFollow() {
|
|
this.runLogAutoScroll = true;
|
|
this.$nextTick(() => this.scrollRunLogToBottom(true));
|
|
},
|
|
|
|
visibleRuns() {
|
|
if (this.activeJobName && this.activeJobRunsName === this.activeJobName) {
|
|
return this.activeJobRuns || [];
|
|
}
|
|
const job = this.activeJob;
|
|
return job ? (job.recent_runs || []) : this.recentRuns;
|
|
},
|
|
|
|
pagedVisibleRuns() {
|
|
const runs = this.visibleRuns();
|
|
const start = this.recentRunsPage * this.recentRunsPerPage;
|
|
return runs.slice(start, start + this.recentRunsPerPage);
|
|
},
|
|
|
|
pageRecentRuns(delta) {
|
|
const maxPage = Math.max(0, Math.ceil(this.visibleRuns().length / this.recentRunsPerPage) - 1);
|
|
this.recentRunsPage = Math.min(maxPage, Math.max(0, this.recentRunsPage + delta));
|
|
},
|
|
|
|
recentRunsRangeText() {
|
|
const total = this.visibleRuns().length;
|
|
if (!total) return '0 runs';
|
|
const start = this.recentRunsPage * this.recentRunsPerPage + 1;
|
|
const end = Math.min((this.recentRunsPage + 1) * this.recentRunsPerPage, total);
|
|
return `${start}-${end} of ${total}`;
|
|
},
|
|
|
|
get activeJob() {
|
|
return this.jobs.find(job => job.name === this.activeJobName) || null;
|
|
},
|
|
|
|
selectVisibleLibrary() {
|
|
this.librarySelectionScope = 'ids';
|
|
const selected = {};
|
|
for (const item of this.library.items) selected[`${item.kind}:${item.id}`] = true;
|
|
this.selectedLibraryIds = selected;
|
|
},
|
|
|
|
selectLibraryFilter() {
|
|
this.librarySelectionScope = 'filter';
|
|
this.selectedLibraryIds = {};
|
|
},
|
|
|
|
clearLibrarySelection() {
|
|
this.librarySelectionScope = 'ids';
|
|
this.selectedLibraryIds = {};
|
|
},
|
|
|
|
openEditor(item) {
|
|
if (!item) return;
|
|
this.activeLibraryItem = item;
|
|
this.editorDraft = {
|
|
title: item.title || '',
|
|
hidden: item.is_hidden ? 'true' : 'false',
|
|
release_type: 'album',
|
|
year: '',
|
|
release_id: null,
|
|
track_number: '',
|
|
disc_number: '',
|
|
artist_ids: [],
|
|
release_tracks: []
|
|
};
|
|
this.editorDetail = null;
|
|
this.editorImageFile = null;
|
|
this.editorArtistToAdd = '';
|
|
this.editorReleaseToAdd = '';
|
|
this.clearReleaseTrackSearch();
|
|
this.editorOpen = true;
|
|
this.loadEditorDetail(item);
|
|
},
|
|
|
|
openReleaseCreator() {
|
|
this.libraryKind = 'releases';
|
|
this.openEditor({
|
|
id: 0,
|
|
kind: 'releases',
|
|
title: '',
|
|
subtitle: 'New release',
|
|
is_hidden: false,
|
|
tags: []
|
|
});
|
|
},
|
|
|
|
async loadEditorDetail(item) {
|
|
const key = `${item.kind}:${item.id}`;
|
|
this.editorLoading = true;
|
|
try {
|
|
const params = new URLSearchParams({ kind: item.kind, id: item.id });
|
|
const detail = await this.request(`${this.apiBase}/library/item/detail?${params.toString()}`);
|
|
if (!this.activeLibraryItem || `${this.activeLibraryItem.kind}:${this.activeLibraryItem.id}` !== key) return;
|
|
this.editorDetail = detail;
|
|
this.editorDraft = {
|
|
title: detail.title || '',
|
|
hidden: detail.hidden ? 'true' : 'false',
|
|
release_type: detail.release_type || 'album',
|
|
year: detail.year || '',
|
|
release_id: detail.release_id || null,
|
|
track_number: detail.track_number || '',
|
|
disc_number: detail.disc_number || '',
|
|
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [],
|
|
release_tracks: Array.isArray(detail.release_tracks) ? detail.release_tracks.map(track => this.normalizeReleaseTrack(track)) : []
|
|
};
|
|
this.editorImageFile = null;
|
|
this.editorArtistToAdd = '';
|
|
this.editorReleaseToAdd = '';
|
|
this.clearReleaseTrackSearch();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
if (this.activeLibraryItem && `${this.activeLibraryItem.kind}:${this.activeLibraryItem.id}` === key) {
|
|
this.editorLoading = false;
|
|
}
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
isArtistEditor() {
|
|
return this.activeLibraryItem && this.activeLibraryItem.kind === 'artists';
|
|
},
|
|
|
|
isReleaseEditor() {
|
|
return this.activeLibraryItem && this.activeLibraryItem.kind === 'releases';
|
|
},
|
|
|
|
isTrackEditor() {
|
|
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
|
|
},
|
|
|
|
editorIsNewRelease() {
|
|
return this.isReleaseEditor() && Number(this.activeLibraryItem.id || 0) === 0;
|
|
},
|
|
|
|
editorTitle() {
|
|
if (this.editorIsNewRelease()) return 'New release';
|
|
return this.activeLibraryItem?.title || 'Editor';
|
|
},
|
|
|
|
editorSubtitle() {
|
|
if (this.editorIsNewRelease()) return 'Create release and attach tracks';
|
|
return this.activeLibraryItem?.kind || 'Library entity';
|
|
},
|
|
|
|
canEditLibraryImage() {
|
|
return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
|
|
},
|
|
|
|
canShowMetadataTags() {
|
|
return this.isArtistEditor() || this.isReleaseEditor() || this.isTrackEditor();
|
|
},
|
|
|
|
metadataTags() {
|
|
return this.editorDetail && Array.isArray(this.editorDetail.metadata_tags)
|
|
? this.editorDetail.metadata_tags
|
|
: [];
|
|
},
|
|
|
|
metadataTagClass(tag) {
|
|
const source = String((tag && tag.source) || 'unknown').toLowerCase().replace(/_/g, '-');
|
|
return `metadata-${source}`;
|
|
},
|
|
|
|
metadataTagSourceLabel(tag) {
|
|
const source = String((tag && tag.source) || '').toLowerCase();
|
|
if (source === 'lastfm') return 'Last.fm';
|
|
if (source === 'file') return 'File';
|
|
if (source === 'review') return 'Review';
|
|
if (source === 'track_genre') return 'Track genre';
|
|
if (source === 'release_lastfm') return 'Release Last.fm';
|
|
if (source === 'release_file') return 'Release file';
|
|
if (source === 'release_review') return 'Release review';
|
|
return source || 'Source';
|
|
},
|
|
|
|
metadataTagScore(tag) {
|
|
const source = String((tag && tag.source) || '').toLowerCase();
|
|
const weight = Number((tag && tag.weight) || 0);
|
|
return (source === 'lastfm' || source === 'release_lastfm') && weight > 1 ? String(Math.round(weight)) : '';
|
|
},
|
|
|
|
metadataTagTitle(tag) {
|
|
if (!tag) return '';
|
|
const parts = [this.metadataTagSourceLabel(tag)];
|
|
const weight = Number(tag.weight || 0);
|
|
if (weight > 0) parts.push(`weight ${Math.round(weight)}`);
|
|
if (tag.updated_at) parts.push(tag.updated_at);
|
|
return parts.join(' / ');
|
|
},
|
|
|
|
editorCanSave() {
|
|
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
|
|
if (!String(this.editorDraft.title || '').trim()) return false;
|
|
if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
|
|
return true;
|
|
},
|
|
|
|
selectedEditorArtists() {
|
|
const selected = this.editorDraft.artist_ids || [];
|
|
const artists = (this.editorDetail && this.editorDetail.artists) || [];
|
|
return selected.map(id => {
|
|
const artist = artists.find(row => Number(row.id) === Number(id));
|
|
return artist || { id, name: `Artist #${id}` };
|
|
});
|
|
},
|
|
|
|
editorAvailableArtists() {
|
|
const selected = new Set((this.editorDraft.artist_ids || []).map(id => Number(id)));
|
|
const artists = (this.editorDetail && this.editorDetail.artists) || [];
|
|
return artists.filter(artist => !selected.has(Number(artist.id)));
|
|
},
|
|
|
|
normalizedArtistSearch(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
},
|
|
|
|
filteredEditorArtists() {
|
|
const raw = String(this.editorArtistToAdd || '').trim();
|
|
const query = this.normalizedArtistSearch(raw);
|
|
const candidates = this.editorAvailableArtists();
|
|
if (!query) return candidates.slice(0, 12);
|
|
return candidates
|
|
.map(artist => {
|
|
const name = this.normalizedArtistSearch(artist.name);
|
|
let score = 3;
|
|
if (name === query) score = 0;
|
|
else if (name.startsWith(query)) score = 1;
|
|
else if (name.includes(query)) score = 2;
|
|
return { artist, score };
|
|
})
|
|
.filter(row => row.score < 3)
|
|
.sort((a, b) => a.score - b.score || a.artist.name.localeCompare(b.artist.name))
|
|
.slice(0, 12)
|
|
.map(row => row.artist);
|
|
},
|
|
|
|
editorArtistSearchOpen() {
|
|
return (this.isReleaseEditor() || this.isTrackEditor()) && String(this.editorArtistToAdd || '').trim().length > 0;
|
|
},
|
|
|
|
addEditorArtist(artist = null) {
|
|
const raw = String(this.editorArtistToAdd || '').trim();
|
|
const candidates = this.filteredEditorArtists();
|
|
artist = artist
|
|
|| candidates.find(row => this.normalizedArtistSearch(row.name) === this.normalizedArtistSearch(raw))
|
|
|| candidates[0];
|
|
if (artist && !(this.editorDraft.artist_ids || []).map(Number).includes(Number(artist.id))) {
|
|
this.editorDraft.artist_ids = (this.editorDraft.artist_ids || []).concat([Number(artist.id)]);
|
|
}
|
|
this.editorArtistToAdd = '';
|
|
},
|
|
|
|
removeEditorArtist(id) {
|
|
this.editorDraft.artist_ids = (this.editorDraft.artist_ids || []).filter(value => Number(value) !== Number(id));
|
|
},
|
|
|
|
selectedEditorRelease() {
|
|
const releases = (this.editorDetail && this.editorDetail.releases) || [];
|
|
return releases.find(row => Number(row.id) === Number(this.editorDraft.release_id));
|
|
},
|
|
|
|
selectedEditorReleaseLabel() {
|
|
const release = this.selectedEditorRelease();
|
|
if (!release) return '';
|
|
return release.subtitle ? `${release.title} / ${release.subtitle}` : release.title;
|
|
},
|
|
|
|
filteredEditorReleases() {
|
|
const releases = (this.editorDetail && this.editorDetail.releases) || [];
|
|
const query = String(this.editorReleaseToAdd || '').trim().toLowerCase();
|
|
const currentId = Number(this.editorDraft.release_id || 0);
|
|
const candidates = releases.filter(release => Number(release.id) !== currentId);
|
|
if (!query) return candidates.slice(0, 12);
|
|
return candidates
|
|
.map(release => {
|
|
const haystack = `${release.title || ''} ${release.subtitle || ''}`.toLowerCase();
|
|
let score = 3;
|
|
if (String(release.title || '').toLowerCase() === query) score = 0;
|
|
else if (String(release.title || '').toLowerCase().startsWith(query)) score = 1;
|
|
else if (haystack.includes(query)) score = 2;
|
|
return { release, score };
|
|
})
|
|
.filter(row => row.score < 3)
|
|
.sort((a, b) => a.score - b.score || a.release.title.localeCompare(b.release.title))
|
|
.slice(0, 12)
|
|
.map(row => row.release);
|
|
},
|
|
|
|
editorReleaseSearchOpen() {
|
|
return this.isTrackEditor() && String(this.editorReleaseToAdd || '').trim().length > 0;
|
|
},
|
|
|
|
selectEditorRelease(release = null) {
|
|
const candidates = this.filteredEditorReleases();
|
|
release = release || candidates[0];
|
|
if (!release) return false;
|
|
this.editorDraft.release_id = Number(release.id);
|
|
this.editorReleaseToAdd = '';
|
|
return true;
|
|
},
|
|
|
|
normalizeReleaseTrack(track = {}) {
|
|
const trackNumber = track.track_number;
|
|
const discNumber = track.disc_number;
|
|
return {
|
|
id: Number(track.id),
|
|
title: track.title || `Track #${track.id}`,
|
|
artists: track.artists || '',
|
|
release_id: track.release_id == null ? null : Number(track.release_id),
|
|
release_title: track.release_title || '',
|
|
track_number: trackNumber == null ? '' : String(trackNumber),
|
|
disc_number: discNumber == null ? '' : String(discNumber),
|
|
duration_seconds: Number(track.duration_seconds || 0),
|
|
is_hidden: Boolean(track.is_hidden)
|
|
};
|
|
},
|
|
|
|
releaseTracks() {
|
|
if (!Array.isArray(this.editorDraft.release_tracks)) {
|
|
this.editorDraft.release_tracks = [];
|
|
}
|
|
return this.editorDraft.release_tracks;
|
|
},
|
|
|
|
releaseTrackPayload() {
|
|
return this.releaseTracks().map(track => ({
|
|
id: Number(track.id),
|
|
track_number: track.track_number || '',
|
|
disc_number: track.disc_number || ''
|
|
}));
|
|
},
|
|
|
|
releaseTrackIds() {
|
|
return new Set(this.releaseTracks().map(track => Number(track.id)));
|
|
},
|
|
|
|
releaseTrackSearchOpen() {
|
|
return this.isReleaseEditor() && String(this.releaseTrackSearch || '').trim().length > 0;
|
|
},
|
|
|
|
availableReleaseTrackResults() {
|
|
const selected = this.releaseTrackIds();
|
|
return (this.releaseTrackSearchResults || []).filter(track => !selected.has(Number(track.id)));
|
|
},
|
|
|
|
clearReleaseTrackSearch() {
|
|
this.releaseTrackSearch = '';
|
|
this.releaseTrackSearchResults = [];
|
|
this.releaseTrackSearchLoading = false;
|
|
this.releaseTrackSearchToken += 1;
|
|
},
|
|
|
|
async searchReleaseTracks() {
|
|
const query = String(this.releaseTrackSearch || '').trim();
|
|
if (!query) {
|
|
this.releaseTrackSearchResults = [];
|
|
this.releaseTrackSearchLoading = false;
|
|
return;
|
|
}
|
|
const token = this.releaseTrackSearchToken + 1;
|
|
this.releaseTrackSearchToken = token;
|
|
this.releaseTrackSearchLoading = true;
|
|
try {
|
|
const params = new URLSearchParams({ search: query, limit: '16' });
|
|
const rows = await this.request(`${this.apiBase}/library/tracks/search?${params.toString()}`);
|
|
if (this.releaseTrackSearchToken !== token) return;
|
|
this.releaseTrackSearchResults = Array.isArray(rows) ? rows.map(track => this.normalizeReleaseTrack(track)) : [];
|
|
} catch (error) {
|
|
if (this.releaseTrackSearchToken === token) this.showToast(error.message);
|
|
} finally {
|
|
if (this.releaseTrackSearchToken === token) {
|
|
this.releaseTrackSearchLoading = false;
|
|
this.icons();
|
|
}
|
|
}
|
|
},
|
|
|
|
async addBestReleaseTrack() {
|
|
if (!String(this.releaseTrackSearch || '').trim()) return;
|
|
if (!this.availableReleaseTrackResults().length && !this.releaseTrackSearchLoading) {
|
|
await this.searchReleaseTracks();
|
|
}
|
|
const track = this.availableReleaseTrackResults()[0];
|
|
if (!track) {
|
|
this.showToast('Choose a track from search results');
|
|
return;
|
|
}
|
|
this.addReleaseTrack(track);
|
|
},
|
|
|
|
addReleaseTrack(track) {
|
|
if (!track) return;
|
|
const normalized = this.normalizeReleaseTrack(track);
|
|
if (this.releaseTrackIds().has(Number(normalized.id))) {
|
|
this.showToast('Track already in release');
|
|
return;
|
|
}
|
|
this.editorDraft.release_tracks = this.releaseTracks().concat([normalized]);
|
|
this.clearReleaseTrackSearch();
|
|
this.$nextTick(() => this.icons());
|
|
},
|
|
|
|
removeReleaseTrack(id) {
|
|
this.editorDraft.release_tracks = this.releaseTracks().filter(track => Number(track.id) !== Number(id));
|
|
},
|
|
|
|
releaseTrackOrigin(track) {
|
|
const releaseId = Number(track && track.release_id ? track.release_id : 0);
|
|
const currentId = Number(this.activeLibraryItem && this.activeLibraryItem.id ? this.activeLibraryItem.id : 0);
|
|
if (releaseId && releaseId === currentId) return this.editorDraft.title || track.release_title || 'This release';
|
|
if (track && track.release_title) return track.release_title;
|
|
if (releaseId) return `Missing release #${releaseId}`;
|
|
return 'No release';
|
|
},
|
|
|
|
releaseTrackSearchMeta(track) {
|
|
const parts = [];
|
|
if (track.artists) parts.push(track.artists);
|
|
parts.push(this.releaseTrackOrigin(track));
|
|
const number = this.releaseTrackNumberLabel(track);
|
|
if (number) parts.push(number);
|
|
return parts.join(' / ');
|
|
},
|
|
|
|
releaseTrackNumberLabel(track) {
|
|
const disc = String((track && track.disc_number) || '').trim();
|
|
const number = String((track && track.track_number) || '').trim();
|
|
if (disc && number) return `D${disc} #${number}`;
|
|
if (number) return `#${number}`;
|
|
if (disc) return `D${disc}`;
|
|
return '';
|
|
},
|
|
|
|
trackDuration(seconds) {
|
|
const total = Math.round(Number(seconds || 0));
|
|
if (!total) return '-';
|
|
const minutes = Math.floor(total / 60);
|
|
const rest = String(total % 60).padStart(2, '0');
|
|
return `${minutes}:${rest}`;
|
|
},
|
|
|
|
setEditorImageFile(event) {
|
|
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
|
|
},
|
|
|
|
readFileAsDataUrl(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result);
|
|
reader.onerror = () => reject(reader.error || new Error('failed to read file'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
},
|
|
|
|
async uploadLibraryImage() {
|
|
if (!this.activeLibraryItem || !this.editorImageFile || this.editorImageUploading) return;
|
|
this.editorImageUploading = true;
|
|
try {
|
|
const dataUrl = await this.readFileAsDataUrl(this.editorImageFile);
|
|
const data = String(dataUrl).split(',')[1] || '';
|
|
await this.request(`${this.apiBase}/library/item/upload-image`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
kind: this.activeLibraryItem.kind,
|
|
id: this.activeLibraryItem.id,
|
|
filename: this.editorImageFile.name,
|
|
mime_type: this.editorImageFile.type || 'application/octet-stream',
|
|
data
|
|
})
|
|
});
|
|
await this.loadEditorDetail(this.activeLibraryItem);
|
|
this.showToast('Image uploaded');
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.editorImageUploading = false;
|
|
if (this.$refs.libraryImageInput) this.$refs.libraryImageInput.value = '';
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async setLibraryImage(mediaFileId) {
|
|
if (!this.activeLibraryItem || this.editorImageUploading) return;
|
|
this.editorImageUploading = true;
|
|
try {
|
|
await this.request(`${this.apiBase}/library/item/image`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
kind: this.activeLibraryItem.kind,
|
|
id: this.activeLibraryItem.id,
|
|
media_file_id: mediaFileId
|
|
})
|
|
});
|
|
await this.loadEditorDetail(this.activeLibraryItem);
|
|
this.showToast(mediaFileId ? 'Image selected' : 'Image removed');
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.editorImageUploading = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
removeLibraryImage() {
|
|
this.setLibraryImage(null);
|
|
},
|
|
|
|
toggleLibrary(item) {
|
|
this.librarySelectionScope = 'ids';
|
|
const key = `${item.kind}:${item.id}`;
|
|
if (this.selectedLibraryIds[key]) delete this.selectedLibraryIds[key];
|
|
else this.selectedLibraryIds[key] = true;
|
|
this.selectedLibraryIds = Object.assign({}, this.selectedLibraryIds);
|
|
},
|
|
|
|
isLibrarySelected(item) {
|
|
return this.librarySelectionScope === 'filter' || Boolean(this.selectedLibraryIds[`${item.kind}:${item.id}`]);
|
|
},
|
|
|
|
selectedLibraryCount() {
|
|
if (this.librarySelectionScope === 'filter') return this.library.total || 0;
|
|
return this.currentLibrarySelectionKeys().length;
|
|
},
|
|
|
|
currentLibrarySelectionKeys() {
|
|
return Object.keys(this.selectedLibraryIds).filter(key => key.startsWith(`${this.libraryKind}:`));
|
|
},
|
|
|
|
async bulkLibrary(action) {
|
|
const count = this.selectedLibraryCount();
|
|
if (!count) return;
|
|
if (action === 'delete' && !confirm(`Delete ${count} ${this.libraryKind}?`)) return;
|
|
const ids = this.currentLibrarySelectionKeys()
|
|
.map(key => Number(key.split(':')[1]))
|
|
.filter(Boolean);
|
|
try {
|
|
const result = await this.request(`${this.apiBase}/library/bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
action,
|
|
kind: this.libraryKind,
|
|
mode: this.librarySelectionScope,
|
|
ids,
|
|
filter: { search: this.librarySearch || null }
|
|
})
|
|
});
|
|
this.showToast(`${result.affected} ${this.libraryKind} updated`);
|
|
this.clearLibrarySelection();
|
|
await this.loadLibrary(false);
|
|
await this.refreshCountsOnly();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
}
|
|
},
|
|
|
|
async saveLibraryItem() {
|
|
if (this.isTrackEditor() && String(this.editorReleaseToAdd || '').trim()) {
|
|
if (!this.selectEditorRelease()) {
|
|
this.showToast('Choose a release from search results');
|
|
return;
|
|
}
|
|
}
|
|
if (!this.editorCanSave()) return;
|
|
this.editorSaving = true;
|
|
try {
|
|
const wasNewRelease = this.editorIsNewRelease();
|
|
const updated = await this.request(`${this.apiBase}/library/item`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
kind: this.activeLibraryItem.kind,
|
|
id: this.activeLibraryItem.id,
|
|
title: this.editorDraft.title,
|
|
hidden: this.editorDraft.hidden === 'true',
|
|
release_type: this.editorDraft.release_type || null,
|
|
year: this.editorDraft.year || '',
|
|
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
|
|
track_number: this.editorDraft.track_number || '',
|
|
disc_number: this.editorDraft.disc_number || '',
|
|
artist_ids: this.editorDraft.artist_ids || [],
|
|
release_tracks: this.isReleaseEditor() ? this.releaseTrackPayload() : null
|
|
})
|
|
});
|
|
this.replaceLibraryItem(updated);
|
|
this.activeLibraryItem = updated;
|
|
if (this.editorDetail) this.editorDetail.item = updated;
|
|
if (this.isReleaseEditor()) await this.loadEditorDetail(updated);
|
|
this.showToast(wasNewRelease ? 'Release created' : 'Saved');
|
|
await this.refreshCountsOnly();
|
|
} catch (error) {
|
|
this.showToast(error.message);
|
|
} finally {
|
|
this.editorSaving = false;
|
|
this.icons();
|
|
}
|
|
},
|
|
|
|
async deleteLibraryItem(item) {
|
|
if (!item) return;
|
|
if (!confirm(`Delete "${item.title}"?`)) return;
|
|
this.librarySelectionScope = 'ids';
|
|
this.selectedLibraryIds = { [`${item.kind}:${item.id}`]: true };
|
|
await this.bulkLibrary('delete');
|
|
this.editorOpen = false;
|
|
this.activeLibraryItem = null;
|
|
},
|
|
|
|
replaceLibraryItem(updated) {
|
|
let replaced = false;
|
|
this.library.items = this.library.items.map(item => {
|
|
if (item.kind === updated.kind && Number(item.id) === Number(updated.id)) {
|
|
replaced = true;
|
|
return updated;
|
|
}
|
|
return item;
|
|
});
|
|
if (!replaced && updated.kind === this.libraryKind) {
|
|
this.library.items = [updated].concat(this.library.items || []);
|
|
this.library.total = Number(this.library.total || 0) + 1;
|
|
}
|
|
},
|
|
|
|
async refreshCountsOnly() {
|
|
try {
|
|
const data = await this.request(`${this.apiBase}/dashboard`);
|
|
this.stats = data.stats || this.stats;
|
|
this.libraryOverview = data.library || this.libraryOverview;
|
|
} catch (_) {}
|
|
},
|
|
|
|
pageReviews(delta) {
|
|
const next = Math.max(0, (this.reviews.offset || 0) + delta * (this.reviews.limit || 80));
|
|
if (next === this.reviews.offset) return;
|
|
this.reviews.offset = next;
|
|
this.loadReviews(false);
|
|
},
|
|
|
|
pageLibrary(delta) {
|
|
const next = Math.max(0, (this.library.offset || 0) + delta * (this.library.limit || 40));
|
|
if (next === this.library.offset) return;
|
|
this.library.offset = next;
|
|
this.loadLibrary(false);
|
|
},
|
|
|
|
pageUsers(delta) {
|
|
const next = Math.max(0, (this.users.offset || 0) + delta * (this.users.limit || 40));
|
|
if (next === this.users.offset) return;
|
|
this.users.offset = next;
|
|
this.loadUsers(false);
|
|
},
|
|
|
|
statCells() {
|
|
const agent = (this.runtime && this.runtime.agent) || {};
|
|
const storage = (this.runtime && this.runtime.storage) || [];
|
|
const node = (this.runtime && this.runtime.node) || {};
|
|
const inbox = storage.find(item => item.label === 'Inbox') || {};
|
|
const library = storage.find(item => item.label === 'Library') || {};
|
|
return [
|
|
{ label: 'Tracks', value: this.stats.tracks || 0 },
|
|
{ label: 'Releases', value: this.stats.releases || 0 },
|
|
{ label: 'Artists', value: this.stats.artists || 0 },
|
|
{ label: 'Playlists', value: this.stats.playlists || 0 },
|
|
{
|
|
label: agent.model ? `AI agent · ${agent.model}` : 'AI agent',
|
|
display: agent.status || 'unknown',
|
|
title: `enabled: ${agent.enabled ? 'yes' : 'no'} · llm: ${agent.llm_configured ? 'configured' : 'missing'} · concurrency: ${agent.concurrency || 0}`
|
|
},
|
|
this.storageStatCell(inbox, 'Inbox disk'),
|
|
this.storageStatCell(library, 'Library disk'),
|
|
{
|
|
label: `${node.hostname || 'node'} · pid ${node.pid || '?'}`,
|
|
display: `${node.cpu_count || '?'} CPU`,
|
|
title: `${node.os || 'unknown'} / ${node.arch || 'unknown'}`
|
|
}
|
|
];
|
|
},
|
|
|
|
storageStatCell(item, fallbackLabel) {
|
|
const free = item && item.free_bytes != null ? item.free_bytes : null;
|
|
const total = item && item.total_bytes != null ? item.total_bytes : null;
|
|
const suffix = item && item.exists === false ? ' · missing' : '';
|
|
return {
|
|
label: `${item.label || fallbackLabel}${suffix}`,
|
|
display: free != null ? `${this.formatBytes(free)} free` : 'n/a',
|
|
title: `${item.path || 'not configured'}${total != null ? ` · ${this.formatBytes(total)} total` : ''}`
|
|
};
|
|
},
|
|
|
|
formatBytes(bytes) {
|
|
const value = Number(bytes || 0);
|
|
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
let size = value;
|
|
let unit = 0;
|
|
while (size >= 1024 && unit < units.length - 1) {
|
|
size /= 1024;
|
|
unit += 1;
|
|
}
|
|
const digits = size >= 10 || unit === 0 ? 0 : 1;
|
|
return `${size.toFixed(digits)} ${units[unit]}`;
|
|
},
|
|
|
|
pageTitle() {
|
|
if (this.activeView === 'library') return 'Library Workbench';
|
|
if (this.activeView === 'jobs') return 'Tasks';
|
|
if (this.activeView === 'tools') return 'Future Tools';
|
|
if (this.activeView === 'settings') return 'Settings';
|
|
if (this.activeView === 'users') return 'Users';
|
|
return 'Review Queue';
|
|
},
|
|
|
|
pageSubtitle() {
|
|
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, tracks, and playlists';
|
|
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
|
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
|
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
|
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
|
|
return 'Full-screen review triage with filter-aware bulk actions';
|
|
},
|
|
|
|
reviewPanelSubtitle() {
|
|
const total = this.reviews.total || 0;
|
|
const totalAll = this.reviewTotalAll();
|
|
const status = this.reviewFilter.status || 'all statuses';
|
|
if (this.reviewFilter.status) return `${this.fmt(total)} ${status} · ${this.fmt(totalAll)} total`;
|
|
return `${this.fmt(totalAll)} rows · ${status}`;
|
|
},
|
|
|
|
jobPanelSubtitle() {
|
|
const running = this.jobs.filter(job => job.is_running).length;
|
|
return `${this.jobs.length} jobs · ${running} running`;
|
|
},
|
|
|
|
librarySubtitle() {
|
|
return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`;
|
|
},
|
|
|
|
usersSubtitle() {
|
|
return `${this.fmt(this.users.total || 0)} users · ${this.fmt(this.users.online_count || 0)} online`;
|
|
},
|
|
|
|
userStatusClass(user) {
|
|
return user?.is_online ? 'ok' : 'disabled';
|
|
},
|
|
|
|
userStatusLabel(user) {
|
|
return user?.is_online ? 'online' : 'offline';
|
|
},
|
|
|
|
userLastSeenLabel(user) {
|
|
if (!user || user.last_seen_ms == null) return 'never in this session';
|
|
if (user.is_online) return 'online now';
|
|
return this.durationApprox(user.last_seen_ms / 1000) + ' ago';
|
|
},
|
|
|
|
usersRangeText() {
|
|
const total = this.users.total || 0;
|
|
if (!total) return 'No users';
|
|
const start = (this.users.offset || 0) + 1;
|
|
const end = Math.min(total, (this.users.offset || 0) + (this.users.limit || 40));
|
|
return `${start}-${end} of ${this.fmt(total)}`;
|
|
},
|
|
|
|
userStatCards() {
|
|
const stats = this.activeUserDetail?.stats || {};
|
|
return [
|
|
{ label: 'Plays', display: this.fmt(stats.plays || 0) },
|
|
{ label: 'Completed plays', display: this.fmt(stats.completed_plays || 0) },
|
|
{ label: 'Listened time', display: this.durationApprox(stats.listened_seconds || 0) },
|
|
{ label: 'Liked tracks', display: this.fmt(stats.liked_tracks || 0) },
|
|
{ label: 'Followed artists', display: this.fmt(stats.followed_artists || 0) },
|
|
{ label: 'Own playlists', display: this.fmt(stats.own_playlists || 0) },
|
|
{ label: 'Saved playlists', display: this.fmt(stats.saved_playlists || 0) },
|
|
{ label: 'Uploaded tracks', display: this.fmt(stats.uploaded_tracks || 0) },
|
|
{ label: 'Torrent sessions', display: this.fmt(stats.torrent_sessions || 0) }
|
|
];
|
|
},
|
|
|
|
userPlayListened(play) {
|
|
const listened = play?.duration_listened;
|
|
const duration = play?.track_duration_seconds;
|
|
const listenedText = listened == null ? 'unknown' : this.durationApprox(listened);
|
|
const durationText = duration ? this.durationApprox(duration) : null;
|
|
return durationText ? `${listenedText} / ${durationText}` : listenedText;
|
|
},
|
|
|
|
userPlayMeta(play) {
|
|
const parts = [];
|
|
if (play.release_title) {
|
|
parts.push(play.release_year ? `${play.release_title} (${play.release_year})` : play.release_title);
|
|
}
|
|
if (play.audio_format) parts.push(String(play.audio_format).toUpperCase());
|
|
if (play.audio_bitrate) parts.push(`${play.audio_bitrate} kbps`);
|
|
if (play.uploader_name) parts.push(`uploaded by ${play.uploader_name}`);
|
|
parts.push(play.completed ? 'completed' : 'partial');
|
|
return parts.join(' · ');
|
|
},
|
|
|
|
durationApprox(seconds) {
|
|
const value = Math.max(0, Number(seconds || 0));
|
|
if (value < 60) return `${Math.floor(value)}s`;
|
|
if (value < 3600) return `${Math.floor(value / 60)}m`;
|
|
if (value < 86400) return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
|
|
return `${Math.floor(value / 86400)}d ${Math.floor((value % 86400) / 3600)}h`;
|
|
},
|
|
|
|
librarySelectionSummary() {
|
|
const count = this.selectedLibraryCount();
|
|
if (!count) return 'Nothing selected';
|
|
return this.librarySelectionScope === 'filter' ? `${count} selected by filter` : `${count} selected`;
|
|
},
|
|
|
|
reviewRangeText() {
|
|
if (!this.reviews.total) return '0 rows';
|
|
const start = (this.reviews.offset || 0) + 1;
|
|
const end = Math.min((this.reviews.offset || 0) + (this.reviews.limit || 80), this.reviews.total);
|
|
return `${start}-${end} of ${this.fmt(this.reviews.total)}`;
|
|
},
|
|
|
|
libraryRangeText() {
|
|
if (!this.library.total) return '0 rows';
|
|
const start = (this.library.offset || 0) + 1;
|
|
const end = Math.min((this.library.offset || 0) + (this.library.limit || 40), this.library.total);
|
|
return `${start}-${end} of ${this.fmt(this.library.total)}`;
|
|
},
|
|
|
|
cronLabel(job) {
|
|
const value = job && job.cron_expression ? String(job.cron_expression).trim() : '';
|
|
return value || 'manual only';
|
|
},
|
|
|
|
statusCount(status) {
|
|
const row = (this.reviews.status_counts || []).find(item => item.status === status);
|
|
return row ? row.count : 0;
|
|
},
|
|
|
|
reviewTotalAll() {
|
|
const explicit = Number(this.reviews.total_all || 0);
|
|
if (explicit > 0 || Object.prototype.hasOwnProperty.call(this.reviews || {}, 'total_all')) return explicit;
|
|
return (this.reviews.status_counts || []).reduce((sum, row) => sum + Number(row.count || 0), 0);
|
|
},
|
|
|
|
formatConfidence(value) {
|
|
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
|
|
},
|
|
|
|
duration(ms) {
|
|
if (!ms && ms !== 0) return '-';
|
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
},
|
|
|
|
runChipLabel(run) {
|
|
return `#${run.id}`;
|
|
},
|
|
|
|
runTitle(run) {
|
|
const parts = [
|
|
`#${run.id}`,
|
|
run.status,
|
|
run.started_at ? `started ${this.shortDate(run.started_at)}` : '',
|
|
run.duration_ms || run.duration_ms === 0 ? `duration ${this.duration(run.duration_ms)}` : '',
|
|
run.error_message || run.log_excerpt || ''
|
|
];
|
|
return parts.filter(Boolean).join(' · ');
|
|
},
|
|
|
|
relativeDate(value) {
|
|
if (!value) return '-';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
|
const abs = Math.abs(seconds);
|
|
if (abs < 60) return seconds >= 0 ? 'now' : 'just now';
|
|
if (abs < 3600) return `${Math.round(abs / 60)}m ${seconds >= 0 ? 'left' : 'ago'}`;
|
|
if (abs < 86400) return `${Math.round(abs / 3600)}h ${seconds >= 0 ? 'left' : 'ago'}`;
|
|
return date.toLocaleDateString();
|
|
},
|
|
|
|
shortDate(value) {
|
|
if (!value) return '-';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString([], { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
},
|
|
|
|
fmt(value) {
|
|
return new Intl.NumberFormat().format(value || 0);
|
|
},
|
|
|
|
mockAction(message) {
|
|
this.showToast(message);
|
|
},
|
|
|
|
showToast(message) {
|
|
this.toastMessage = message;
|
|
setTimeout(() => {
|
|
if (this.toastMessage === message) this.toastMessage = '';
|
|
}, 3400);
|
|
},
|
|
|
|
icons() {
|
|
this.$nextTick(() => {
|
|
if (window.lucide) window.lucide.createIcons();
|
|
});
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock content %}
|