2026-05-26 00:19:11 +03:00
{% 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 : 244 px ;
}
* { box-sizing : border-box ; }
[ x-cloak ] { display : none !important ; }
html , body {
width : 100 % ;
height : 100 % ;
min-width : 1180 px ;
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 , 1 fr );
height : 100 vh ;
height : 100 dvh ;
overflow : hidden ;
}
. sidebar {
min-width : 0 ;
border-right : 1 px solid var ( -- border - color );
background : var ( -- bg - secondary );
display : flex ;
flex-direction : column ;
}
. brand {
padding : 18 px 16 px ;
border-bottom : 1 px solid var ( -- border - color );
}
. brand-title {
display : flex ;
align-items : baseline ;
gap : 8 px ;
font-size : 18 px ;
font-weight : 800 ;
}
. version {
color : var ( -- text - subdued );
font-size : 11 px ;
font-weight : 700 ;
}
. admin-user {
margin-top : 8 px ;
color : var ( -- text - subdued );
font-size : 12 px ;
}
. nav-group {
padding : 12 px ;
}
. nav-label {
padding : 8 px 8 px 6 px ;
color : var ( -- text - subdued );
font-size : 10 px ;
font-weight : 800 ;
letter-spacing : 0 ;
text-transform : uppercase ;
}
. nav-btn {
width : 100 % ;
height : 38 px ;
padding : 0 10 px ;
border-radius : 6 px ;
background : transparent ;
color : var ( -- text - secondary );
display : grid ;
grid-template-columns : 18 px minmax ( 0 , 1 fr ) auto ;
align-items : center ;
gap : 10 px ;
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 : 16 px ;
height : 16 px ;
}
. nav-count {
color : var ( -- text - subdued );
font-size : 11 px ;
}
. main {
min-width : 0 ;
min-height : 0 ;
overflow : auto ;
background : var ( -- bg - primary );
}
. topbar {
position : sticky ;
top : 0 ;
z-index : 5 ;
height : 64 px ;
padding : 12 px 18 px ;
border-bottom : 1 px solid var ( -- border - color );
background : rgba ( 18 , 18 , 18 , 0.96 );
backdrop-filter : blur ( 12 px );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 16 px ;
}
. page-title {
min-width : 0 ;
}
. page-title h1 {
margin : 0 ;
font-size : 20 px ;
line-height : 1.1 ;
}
. page-title p {
margin : 4 px 0 0 ;
color : var ( -- text - subdued );
font-size : 12 px ;
}
. top-actions {
display : flex ;
align-items : center ;
gap : 8 px ;
}
. content {
padding : 18 px ;
}
. stats-strip {
display : grid ;
grid-template-columns : repeat ( 7 , minmax ( 104 px , 1 fr ));
gap : 8 px ;
margin-bottom : 14 px ;
}
. stat-cell {
min-width : 0 ;
padding : 10 px 12 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - secondary );
}
. stat-value {
font-size : 18 px ;
font-weight : 800 ;
}
. stat-label {
margin-top : 2 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
}
. panel {
min-width : 0 ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - secondary );
}
. panel-head {
min-height : 56 px ;
padding : 12 px ;
border-bottom : 1 px solid var ( -- border - color );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
}
. panel-title {
min-width : 0 ;
}
. panel-title strong {
display : block ;
font-size : 14 px ;
}
. panel-title span {
display : block ;
margin-top : 2 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
}
. toolbar {
display : flex ;
align-items : center ;
gap : 8 px ;
flex-wrap : wrap ;
}
. btn ,
. icon-btn ,
. seg-btn {
height : 32 px ;
border-radius : 6 px ;
background : var ( -- bg - elevated );
color : var ( -- text - secondary );
display : inline-flex ;
align-items : center ;
justify-content : center ;
gap : 7 px ;
cursor : pointer ;
}
. btn {
padding : 0 12 px ;
font-size : 12 px ;
font-weight : 800 ;
}
. icon-btn {
width : 32 px ;
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 {
opacity : 0.45 ;
cursor : not-allowed ;
}
. segmented {
display : inline-flex ;
padding : 3 px ;
border-radius : 8 px ;
background : var ( -- bg - primary );
}
. seg-btn {
min-width : 76 px ;
height : 28 px ;
padding : 0 10 px ;
background : transparent ;
font-size : 12 px ;
}
. seg-btn . active {
background : var ( -- bg - elevated );
color : var ( -- text - primary );
}
. search {
width : 260 px ;
height : 32 px ;
padding : 0 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 6 px ;
background : var ( -- bg - primary );
color : var ( -- text - primary );
outline : none ;
}
. search : focus {
border-color : var ( -- accent );
}
. action-strip {
min-height : 44 px ;
padding : 8 px 12 px ;
border-bottom : 1 px solid var ( -- border - color );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 10 px ;
}
. selection-summary {
color : var ( -- text - subdued );
font-size : 12 px ;
}
. table-wrap {
max-height : 560 px ;
overflow : auto ;
}
. review-table-wrap {
max-height : calc ( 100 vh - 318 px );
}
table {
width : 100 % ;
border-collapse : collapse ;
table-layout : fixed ;
}
th {
position : sticky ;
top : 0 ;
z-index : 2 ;
height : 34 px ;
padding : 0 10 px ;
background : var ( -- bg - secondary );
color : var ( -- text - subdued );
border-bottom : 1 px solid var ( -- border - color );
font-size : 11 px ;
font-weight : 800 ;
text-align : left ;
text-transform : uppercase ;
}
td {
height : 54 px ;
padding : 8 px 10 px ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.055 );
color : var ( -- text - secondary );
font-size : 12 px ;
vertical-align : middle ;
}
tr {
cursor : default ;
}
tbody tr : hover {
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. col-check { width : 38 px ; }
. col-status { width : 112 px ; }
. col-type { width : 120 px ; }
. col-confidence { width : 88 px ; }
. col-tags { width : 250 px ; }
. col-time { width : 132 px ; }
. check {
width : 16 px ;
height : 16 px ;
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 : 3 px ;
color : var ( -- text - subdued );
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. badge {
display : inline-flex ;
align-items : center ;
height : 22 px ;
padding : 0 8 px ;
border-radius : 999 px ;
background : var ( -- bg - elevated );
color : var ( -- text - secondary );
font-size : 11 px ;
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 : 5 px ;
flex-wrap : wrap ;
max-height : 42 px ;
overflow : hidden ;
}
. tag {
display : inline-flex ;
align-items : center ;
height : 20 px ;
padding : 0 7 px ;
border-radius : 999 px ;
font-size : 10 px ;
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 ; }
. jobs-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 8 px ;
padding : 12 px ;
}
. jobs-page {
display : grid ;
grid-template-columns : minmax ( 720 px , 1 fr ) 380 px ;
grid-template-rows : minmax ( 310 px , 1 fr ) minmax ( 260 px , 0.85 fr );
gap : 14 px ;
align-items : stretch ;
height : calc ( 100 vh - 100 px );
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 ,
. jobs-page . jobs-list-panel . action-strip {
flex-shrink : 0 ;
}
. jobs-page . jobs-list-panel . action-strip {
border-top : 1 px solid var ( -- border - color );
border-bottom : 0 ;
}
. job-table . job-column { width : 28 % ; }
. job-table . state-column { width : 13 % ; }
. job-table . schedule-column { width : 20 % ; }
. job-table . runs-column { width : 29 % ; }
. job-table . actions-column { width : 10 % ; }
. inline-runs {
display : flex ;
flex-wrap : wrap ;
gap : 5 px ;
}
. run-chip {
gap : 5 px ;
max-width : 128 px ;
}
. task-form {
position : sticky ;
top : 82 px ;
grid-column : 2 ;
grid-row : 1 / span 2 ;
align-self : start ;
}
. 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 : 12 px ;
overflow : hidden ;
}
. run-log-output {
width : 100 % ;
height : 100 % ;
min-height : 210 px ;
margin : 0 ;
padding : 14 px 16 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : #0b0d0f ;
color : #dce6ef ;
font-family : ui-monospace , SFMono-Regular , Consolas , "Liberation Mono" , monospace ;
font-size : 13 px ;
line-height : 1.55 ;
white-space : pre-wrap ;
word-break : break-word ;
overflow : auto ;
tab-size : 4 ;
}
. job-card {
min-width : 0 ;
padding : 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
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 : 8 px ;
}
. job-name {
min-width : 0 ;
color : var ( -- text - primary );
font-size : 13 px ;
font-weight : 850 ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. job-desc {
height : 32 px ;
margin-top : 6 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
line-height : 16 px ;
overflow : hidden ;
}
. job-meta {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 6 px ;
margin-top : 8 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
}
. runs {
border-top : 1 px solid var ( -- border - color );
}
. run-row {
display : grid ;
grid-template-columns : 76 px 96 px minmax ( 0 , 1 fr ) 72 px ;
align-items : center ;
gap : 8 px ;
min-height : 42 px ;
padding : 6 px 12 px ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.055 );
color : var ( -- text - secondary );
font-size : 12 px ;
cursor : pointer ;
}
. run-row : hover {
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. library-shell {
display : block ;
}
2026-05-26 18:16:34 +03:00
. settings-page {
2026-05-26 18:40:05 +03:00
max-width : none ;
}
. settings-layout {
2026-05-26 18:16:34 +03:00
display : grid ;
2026-05-26 18:40:05 +03:00
grid-template-columns : minmax ( 620 px , 1 fr ) minmax ( 360 px , 440 px );
2026-05-26 18:16:34 +03:00
gap : 14 px ;
align-items : start ;
}
2026-05-26 18:40:05 +03:00
. settings-column {
display : grid ;
gap : 14 px ;
align-content : start ;
}
. settings-side . settings-grid {
grid-template-columns : minmax ( 0 , 1 fr );
}
. settings-actions {
grid-column : 1 / -1 ;
}
. settings-grid {
display : grid ;
grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ));
gap : 10 px ;
padding : 14 px ;
}
2026-05-26 18:16:34 +03:00
. settings-card {
padding : 14 px ;
}
2026-05-26 18:40:05 +03:00
. setting-field {
min-width : 0 ;
}
. setting-field label ,
. setting-toggle label {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 8 px ;
margin-bottom : 6 px ;
color : var ( -- text - secondary );
font-size : 11 px ;
font-weight : 800 ;
text-transform : uppercase ;
}
. setting-field input {
width : 100 % ;
height : 34 px ;
padding : 0 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 6 px ;
background : var ( -- bg - primary );
color : var ( -- text - primary );
outline : none ;
}
. setting-field input : focus {
border-color : var ( -- accent );
}
. setting-toggle {
min-height : 74 px ;
padding : 12 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
}
. setting-toggle-row {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 10 px ;
}
. setting-toggle-row span {
color : var ( -- text - primary );
font-size : 13 px ;
font-weight : 800 ;
}
. setting-toggle input {
width : 18 px ;
height : 18 px ;
accent-color : var ( -- accent );
}
. setting-help {
margin-top : 6 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
line-height : 1.4 ;
}
. source-pill {
flex : 0 0 auto ;
display : inline-flex ;
align-items : center ;
height : 18 px ;
padding : 0 6 px ;
border-radius : 999 px ;
background : rgba ( 255 , 255 , 255 , 0.08 );
color : var ( -- text - subdued );
font-size : 10 px ;
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 ;
}
2026-05-26 18:16:34 +03:00
. settings-note {
padding : 14 px ;
color : var ( -- text - secondary );
font-size : 12 px ;
line-height : 1.55 ;
}
2026-05-26 18:40:05 +03:00
. probe-body {
padding : 14 px ;
}
. probe-intro {
margin : 0 0 12 px ;
color : var ( -- text - primary );
font-size : 13 px ;
line-height : 1.45 ;
}
. probe-table {
display : grid ;
gap : 7 px ;
color : var ( -- text - secondary );
font-size : 12 px ;
}
. probe-row {
display : flex ;
justify-content : space-between ;
gap : 10 px ;
}
2026-05-26 00:19:11 +03:00
. library-row {
display : grid ;
grid-template-columns : 38 px minmax ( 0 , 1 fr ) 300 px 130 px ;
align-items : center ;
gap : 10 px ;
min-height : 58 px ;
padding : 8 px 12 px ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.055 );
}
. library-row : hover ,
. library-row . active {
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. field {
margin-bottom : 12 px ;
}
. field label {
display : block ;
margin-bottom : 6 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
font-weight : 800 ;
text-transform : uppercase ;
}
. field input ,
2026-05-27 15:03:06 +03:00
. field textarea ,
. field select {
2026-05-26 00:19:11 +03:00
width : 100 % ;
min-height : 34 px ;
padding : 8 px 10 px ;
border : 1 px solid var ( -- border - color );
border-radius : 6 px ;
background : var ( -- bg - primary );
color : var ( -- text - primary );
resize : vertical ;
outline : none ;
}
. field textarea {
min-height : 110 px ;
font-family : ui-monospace , SFMono-Regular , Consolas , monospace ;
font-size : 11 px ;
}
2026-05-27 15:56:57 +03:00
. image-editor {
display : grid ;
grid-template-columns : 150 px minmax ( 0 , 1 fr );
gap : 14 px ;
margin-bottom : 12 px ;
}
. image-preview {
width : 150 px ;
aspect-ratio : 1 ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - primary );
display : grid ;
place-items : center ;
overflow : hidden ;
color : var ( -- text - subdued );
font-size : 12 px ;
}
. image-preview img {
width : 100 % ;
height : 100 % ;
object-fit : cover ;
}
. cover-grid {
display : grid ;
grid-template-columns : repeat ( auto - fill , minmax ( 86 px , 1 fr ));
gap : 8 px ;
}
. cover-option {
min-width : 0 ;
padding : 6 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
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 : 5 px ;
display : block ;
}
. cover-option span {
display : block ;
margin-top : 5 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
font-size : 10 px ;
}
. artist-tags {
display : flex ;
gap : 6 px ;
flex-wrap : wrap ;
margin-bottom : 8 px ;
}
. artist-picker {
position : relative ;
}
. artist-picker input {
width : 100 % ;
}
. artist-results {
position : absolute ;
left : 0 ;
right : 0 ;
top : calc ( 100 % + 5 px );
z-index : 5 ;
max-height : 242 px ;
overflow : auto ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - elevated );
box-shadow : 0 14 px 34 px rgba ( 0 , 0 , 0 , 0.38 );
}
. artist-result {
width : 100 % ;
min-height : 34 px ;
padding : 8 px 10 px ;
border : 0 ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.06 );
background : transparent ;
color : var ( -- text - secondary );
text-align : left ;
cursor : pointer ;
}
. artist-result : hover ,
. artist-result : focus {
background : var ( -- bg - hover );
color : var ( -- text - primary );
}
. artist-result : last-child {
border-bottom : 0 ;
}
. tag button {
width : 16 px ;
height : 16 px ;
margin-left : 5 px ;
border : 0 ;
border-radius : 999 px ;
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 , 1 fr ));
gap : 12 px ;
}
. image-actions {
display : flex ;
align-items : center ;
gap : 8 px ;
flex-wrap : wrap ;
margin-bottom : 10 px ;
}
. file-name {
max-width : 280 px ;
color : var ( -- text - subdued );
font-size : 11 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. cover-option . active {
border-color : rgba ( 29 , 185 , 84 , 0.8 );
box-shadow : 0 0 0 1 px rgba ( 29 , 185 , 84 , 0.32 );
}
2026-05-26 00:19:11 +03:00
. muted {
color : var ( -- text - subdued );
}
. empty {
padding : 28 px ;
color : var ( -- text - subdued );
text-align : center ;
}
. toast {
position : fixed ;
right : 18 px ;
bottom : 18 px ;
z-index : 20 ;
max-width : 420 px ;
padding : 10 px 12 px ;
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - elevated );
color : var ( -- text - primary );
box-shadow : 0 12 px 28 px rgba ( 0 , 0 , 0 , 0.35 );
font-size : 12 px ;
}
. loading-mask {
position : absolute ;
inset : 64 px 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 : 28 px ;
background : rgba ( 0 , 0 , 0 , 0.62 );
}
. modal {
width : min ( 760 px , calc ( 100 vw - 80 px ));
max-height : calc ( 100 vh - 80 px );
border : 1 px solid var ( -- border - color );
border-radius : 8 px ;
background : var ( -- bg - secondary );
box-shadow : 0 24 px 72 px rgba ( 0 , 0 , 0 , 0.56 );
overflow : hidden ;
}
. modal-head {
min-height : 58 px ;
padding : 12 px 14 px ;
border-bottom : 1 px solid var ( -- border - color );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
}
. modal-body {
max-height : calc ( 100 vh - 154 px );
padding : 14 px ;
overflow : auto ;
}
</ 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 >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'reviews'}" @ click = "openReviews()" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "inbox" ></ i >
< span > Review Queue</ span >
< span class = "nav-count" x-text = "reviews.total || 0" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'jobs'}" @ click = "openJobs()" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "calendar-clock" ></ i >
< span > Tasks</ span >
< span class = "nav-count" x-text = "jobs.length || 0" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'library'}" @ click = "openLibrary(libraryKind)" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "library" ></ i >
< span > Library Workbench</ span >
< span class = "nav-count" x-text = "fmt(stats.tracks || 0)" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'tools'}" @ click = "openTools()" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "wrench" ></ i >
< span > Future Tools</ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" :class = "{active: activeView === 'settings'}" @ click = "openSettings()" >
2026-05-26 18:16:34 +03:00
< i data-lucide = "settings" ></ i >
< span > Settings</ span >
2026-05-27 16:40:06 +03:00
< span class = "nav-count" x-text = "settings.lastfm_scrobbling_configured ? 'ok' : ''" ></ span >
2026-05-26 18:16:34 +03:00
</ button >
2026-05-26 00:19:11 +03:00
</ div >
< div class = "nav-group" >
< div class = "nav-label" > Entities</ div >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" @ click = "openLibrary('artists')" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "mic-2" ></ i >
< span > Artists</ span >
< span class = "nav-count" x-text = "fmt(libraryOverview.artists || 0)" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" @ click = "openLibrary('releases')" >
2026-05-26 00:19:11 +03:00
< i data-lucide = "disc-3" ></ i >
< span > Releases</ span >
< span class = "nav-count" x-text = "fmt(libraryOverview.releases || 0)" ></ span >
</ button >
2026-05-26 18:40:05 +03:00
< button class = "nav-btn" @ click = "openLibrary('playlists')" >
2026-05-26 00:19:11 +03:00
< 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" >
< div class = "stat-value" x-text = "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) : reviews.total" ></ 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 pagedJobs()" :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, 5)" :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" @ click . stop = "runJob(job)" :disabled = "job.launching" title = "Run now" >
< i data-lucide = "play" ></ i >
</ button >
< button class = "icon-btn" @ 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 >
< div class = "action-strip" >
< span class = "selection-summary" x-text = "jobsRangeText()" ></ span >
< div class = "toolbar" >
< select class = "search" style = "width:92px" x-model . number = "jobsPerPage" @ change = "jobsPage = 0" >
< option value = "8" > 8</ option >
< option value = "12" > 12</ option >
< option value = "20" > 20</ option >
</ select >
< button class = "btn" @ click = "pageJobs(-1)" :disabled = "jobsPage === 0" > Previous</ button >
< button class = "btn" @ click = "pageJobs(1)" :disabled = "(jobsPage + 1) * jobsPerPage >= jobs.length" > Next</ button >
</ div >
</ 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 style = "padding:14px" x-show = "activeJob" >
< div class = "field" >
< label > Description</ label >
< textarea readonly x-text = "activeJob?.description || ''" ></ textarea >
</ div >
< div class = "field" >
< label > Cron</ label >
< input readonly :value = "activeJob?.cron_expression || ''" />
</ div >
< div class = "field" >
< label > Next / Last</ label >
< input readonly :value = "relativeDate(activeJob?.next_run_at) + ' / ' + relativeDate(activeJob?.last_run_at)" />
</ div >
< div class = "toolbar" style = "margin-bottom:12px" >
< button class = "btn primary" @ click = "runJob(activeJob)" >
< i data-lucide = "play" ></ i >
Run now
</ button >
< button class = "btn" @ 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 visibleRuns()" :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 >
</ 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 >
</ div >
</ div >
< div class = "run-log-body" >
< pre class = "run-log-output" x-show = "activeRunDetail" 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" >
2026-05-26 18:40:05 +03:00
< 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 === 'playlists'}" @ click = "openLibrary('playlists')" > Playlists</ button >
2026-05-26 00:19:11 +03:00
</ 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 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 === '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 >
2026-05-26 18:16:34 +03:00
< div class = "content" x-show = "activeView === 'settings'" >
< div class = "settings-page" >
2026-05-26 18:40:05 +03:00
< 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 >
2026-05-26 18:16:34 +03:00
</ div >
2026-05-26 18:40:05 +03:00
< 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 >
2026-05-27 16:40:06 +03:00
< span class = "badge" :class = "settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text = "settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'" ></ span >
2026-05-26 18:40:05 +03:00
</ 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 >
2026-05-27 16:40:06 +03:00
< span > {{ t.settings_lastfm_api_key }}</ span >
2026-05-26 18:40:05 +03:00
< 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" />
2026-05-27 16:40:06 +03:00
< 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 >
2026-05-26 18:40:05 +03:00
</ 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 >
2026-05-26 18:16:34 +03:00
< div class = "toolbar" >
< button class = "btn" type = "button" @ click = "loadSettings()" >
< i data-lucide = "refresh-cw" ></ i >
Reload
</ button >
2026-05-26 18:40:05 +03:00
< 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 >
2026-05-26 18:16:34 +03:00
</ div >
</ div >
2026-05-26 18:40:05 +03:00
</ form >
2026-05-26 18:16:34 +03:00
</ div >
</ div >
2026-05-26 00:19:11 +03:00
</ main >
< 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 >
2026-05-27 15:03:06 +03:00
< 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 >
2026-05-26 00:19:11 +03:00
< div class = "toolbar" >
2026-05-27 15:03:06 +03:00
< button class = "btn primary" x-show = "activeReview?.status === 'pending'" @ click = "approveActiveReview()" >
< i data-lucide = "check" ></ i >
Approve
</ button >
2026-05-26 00:19:11 +03:00
< 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 = "activeLibraryItem?.title || 'Editor'" ></ strong >
< span x-text = "activeLibraryItem?.kind || 'Library entity'" ></ span >
</ div >
< button class = "icon-btn" @ click = "editorOpen = false" >
< i data-lucide = "x" ></ i >
</ button >
</ div >
< div class = "modal-body" x-show = "activeLibraryItem" >
2026-05-27 15:56:57 +03:00
< div class = "empty" x-show = "editorLoading" > Loading editor...</ div >
< div x-show = "!editorLoading" >
< div class = "field" >
< label x-text = "isArtistEditor() ? 'Artist name' : '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 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" >
< 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...' : 'Save'" ></ span >
</ button >
< button class = "btn danger" @ click = "deleteLibraryItem(activeLibraryItem)" :disabled = "editorSaving || editorImageUploading" >
< i data-lucide = "trash-2" ></ i >
Delete
</ button >
</ div >
2026-05-26 00:19:11 +03:00
</ 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 : {},
libraryOverview : {},
reviews : { items : [], total : 0 , limit : 80 , offset : 0 , status_counts : [] },
reviewFilter : { status : null , search : '' },
selectedReviewIds : {},
reviewSelectionScope : 'ids' ,
activeReview : null ,
2026-05-27 15:03:06 +03:00
reviewDraft : {
title : '' ,
artist : '' ,
album : '' ,
year : '' ,
track_number : '' ,
genre : '' ,
featured_artists : '' ,
release_type : 'album' ,
notes : ''
},
2026-05-26 00:19:11 +03:00
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 : [],
activeJobName : null ,
activeRunDetail : null ,
jobsPage : 0 ,
jobsPerPage : 12 ,
libraryKind : 'artists' ,
librarySearch : '' ,
library : { items : [], total : 0 , limit : 40 , offset : 0 },
selectedLibraryIds : {},
librarySelectionScope : 'ids' ,
activeLibraryItem : null ,
editorOpen : false ,
2026-05-27 15:56:57 +03:00
editorLoading : false ,
editorSaving : false ,
editorImageUploading : false ,
editorImageFile : null ,
editorArtistToAdd : '' ,
editorDetail : null ,
editorDraft : { title : '' , hidden : 'false' , release_type : 'album' , year : '' , artist_ids : [] },
2026-05-27 16:40:06 +03:00
settings : { values : {}, sources : {}, lastfm_api_key_configured : false , lastfm_shared_secret_configured : false , lastfm_scrobbling_configured : false },
2026-05-26 18:40:05 +03:00
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 : '' ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : '' ,
2026-05-26 18:40:05 +03:00
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 ,
2026-05-26 00:19:11 +03:00
poller : null ,
async init () {
2026-05-26 18:40:05 +03:00
this . applyRouteFromHash ();
2026-05-26 00:19:11 +03:00
await this . refreshAll ();
2026-05-26 18:40:05 +03:00
this . routeReady = true ;
this . activateCurrentView ( false );
window . addEventListener ( 'hashchange' , () => {
this . applyRouteFromHash ();
this . activateCurrentView ( false );
});
2026-05-26 00:19:11 +03:00
this . poller = setInterval (() => this . poll (), 6000 );
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 . 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 ;
2026-05-26 18:16:34 +03:00
await this . loadSettings ( false );
2026-05-26 00:19:11 +03:00
await this . loadLibrary ( false );
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . loading = false ;
this . icons ();
}
},
async poll () {
if ( this . loading ) return ;
await Promise . allSettled ([ this . loadJobs ( false ), this . loadReviews ( false )]);
},
2026-05-26 18:40:05 +03:00
applyRouteFromHash () {
const raw = ( window . location . hash || '#reviews' ). replace ( /^#\/?/ , '' );
const parts = raw . split ( '/' ). filter ( Boolean );
const view = parts [ 0 ] || 'reviews' ;
if ( view === 'reviews' ) {
2026-05-27 15:56:57 +03:00
const nextStatus = parts [ 1 ] || null ;
if ( this . reviewFilter . status !== nextStatus ) this . clearReviewSelection ();
2026-05-26 18:40:05 +03:00
this . activeView = 'reviews' ;
2026-05-27 15:56:57 +03:00
this . reviewFilter . status = nextStatus ;
2026-05-26 18:40:05 +03:00
} else if ( view === 'jobs' ) {
this . activeView = 'jobs' ;
if ( parts [ 1 ]) this . activeJobName = decodeURIComponent ( parts [ 1 ]);
} else if ( view === 'library' ) {
2026-05-27 15:56:57 +03:00
const nextKind = [ 'artists' , 'releases' , 'playlists' ]. includes ( parts [ 1 ]) ? parts [ 1 ] : 'artists' ;
if ( this . libraryKind !== nextKind ) this . clearLibrarySelection ();
2026-05-26 18:40:05 +03:00
this . activeView = 'library' ;
2026-05-27 15:56:57 +03:00
this . libraryKind = nextKind ;
2026-05-26 18:40:05 +03:00
} else if ( view === 'settings' ) {
this . activeView = 'settings' ;
} 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 === 'tools' && updateRoute ) {
this . setRoute ( '#tools' );
}
},
openReviews ( status = null ) {
2026-05-27 15:56:57 +03:00
if ( this . reviewFilter . status !== status ) this . clearReviewSelection ();
2026-05-26 18:40:05 +03:00
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 . setRoute ( this . activeJobName ? `#jobs/ ${ encodeURIComponent ( this . activeJobName ) } ` : '#jobs' );
this . loadJobs ();
if ( this . activeJobName ) this . loadRunsForJob ( this . activeJobName );
},
openLibrary ( kind = this . libraryKind ) {
this . activeView = 'library' ;
2026-05-27 15:56:57 +03:00
const nextKind = [ 'artists' , 'releases' , 'playlists' ]. includes ( kind ) ? kind : 'artists' ;
if ( this . libraryKind !== nextKind ) this . clearLibrarySelection ();
this . libraryKind = nextKind ;
2026-05-26 18:40:05 +03:00
this . setRoute ( `#library/ ${ this . libraryKind } ` );
this . loadLibrary ();
},
openTools () {
this . activeView = 'tools' ;
this . setRoute ( '#tools' );
},
2026-05-26 00:19:11 +03:00
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 () } ` );
2026-05-27 15:56:57 +03:00
if ( resetOffset ) this . clearReviewSelection ();
2026-05-26 00:19:11 +03:00
} 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 ;
if ( this . jobsPage * this . jobsPerPage >= this . jobs . length ) this . jobsPage = 0 ;
} 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 () } ` );
2026-05-27 15:56:57 +03:00
if ( resetOffset ) this . clearLibrarySelection ();
2026-05-26 00:19:11 +03:00
} catch ( error ) {
this . showToast ( error . message );
} finally {
this . libraryLoading = false ;
this . icons ();
}
},
2026-05-26 18:16:34 +03:00
async loadSettings ( showErrors = true ) {
try {
this . settings = await this . request ( ` ${ this . apiBase } /settings` );
2026-05-26 18:40:05 +03:00
this . settingsDraft = Object . assign ({}, this . settingsDraft , this . settings . values || {});
2026-05-26 18:16:34 +03:00
} catch ( error ) {
if ( showErrors ) this . showToast ( error . message );
} finally {
this . icons ();
}
},
async saveSettings () {
2026-05-26 18:40:05 +03:00
if ( this . settingsSaving ) return ;
this . settingsSaving = true ;
2026-05-26 18:16:34 +03:00
try {
await this . request ( ` ${ this . apiBase } /settings` , {
method : 'POST' ,
2026-05-26 18:40:05 +03:00
body : JSON . stringify ( this . settingsDraft )
2026-05-26 18:16:34 +03:00
});
await this . loadSettings ( false );
this . showToast ( 'Settings saved' );
} catch ( error ) {
this . showToast ( error . message );
2026-05-26 18:40:05 +03:00
} 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 );
2026-05-26 18:16:34 +03:00
}
},
2026-05-26 18:40:05 +03:00
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 ();
},
2026-05-26 00:19:11 +03:00
setReviewStatus ( status ) {
2026-05-26 18:40:05 +03:00
this . openReviews ( status );
2026-05-26 00:19:11 +03:00
},
openReview ( row ) {
this . activeReview = row ;
2026-05-27 15:03:06 +03:00
this . reviewDraft = Object . assign ({
title : '' ,
artist : '' ,
album : '' ,
year : '' ,
track_number : '' ,
genre : '' ,
featured_artists : '' ,
release_type : 'album' ,
notes : ''
}, row . normalized || {});
2026-05-26 00:19:11 +03:00
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` );
2026-05-27 15:56:57 +03:00
this . clearReviewSelection ();
2026-05-26 00:19:11 +03:00
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 ;
},
2026-05-27 15:03:06 +03:00
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 );
}
},
2026-05-26 00:19:11 +03:00
async runJob ( job ) {
job . launching = true ;
try {
const result = await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( job . name ) } /run` , { method : 'POST' });
this . showToast ( `Run # ${ result . run_id } started` );
this . activeJobName = job . name ;
await this . loadJobs ( false );
await this . loadRunsForJob ( job . name );
} catch ( error ) {
this . showToast ( error . message );
} finally {
job . launching = false ;
this . icons ();
}
},
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 ;
2026-05-26 18:40:05 +03:00
this . setRoute ( `#jobs/ ${ encodeURIComponent ( name ) } ` );
2026-05-26 00:19:11 +03:00
this . activeReview = null ;
this . activeRunDetail = null ;
await this . loadRunsForJob ( name );
},
async loadRunsForJob ( name ) {
try {
const data = await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( name ) } /runs` );
const job = this . jobs . find ( item => item . name === name );
if ( job ) job . recent_runs = data . runs ;
} catch ( error ) {
this . showToast ( error . message );
}
},
async loadRunDetail ( run ) {
this . activeReview = null ;
try {
this . activeRunDetail = await this . request ( ` ${ this . apiBase } /jobs/ ${ encodeURIComponent ( run . job_name ) } /runs/ ${ run . id } ` );
this . activeJobName = run . job_name ;
} catch ( error ) {
this . showToast ( error . message );
}
},
visibleRuns () {
const job = this . activeJob ;
return job ? ( job . recent_runs || []) : this . recentRuns ;
},
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 || '' ,
2026-05-27 15:56:57 +03:00
hidden : item . is_hidden ? 'true' : 'false' ,
release_type : 'album' ,
year : '' ,
artist_ids : []
2026-05-26 00:19:11 +03:00
};
2026-05-27 15:56:57 +03:00
this . editorDetail = null ;
this . editorImageFile = null ;
this . editorArtistToAdd = '' ;
2026-05-26 00:19:11 +03:00
this . editorOpen = true ;
2026-05-27 15:56:57 +03:00
this . loadEditorDetail ( item );
},
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 || '' ,
artist_ids : Array . isArray ( detail . selected_artist_ids ) ? detail . selected_artist_ids . slice () : []
};
this . editorImageFile = null ;
this . editorArtistToAdd = '' ;
} 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' ;
},
canEditLibraryImage () {
return this . isArtistEditor () || this . isReleaseEditor ();
},
editorCanSave () {
return Boolean ( this . activeLibraryItem && this . editorDetail && ! this . editorLoading && ! this . editorSaving );
},
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 () && 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 ));
},
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 );
2026-05-26 00:19:11 +03:00
},
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 ;
2026-05-27 15:56:57 +03:00
return this . currentLibrarySelectionKeys (). length ;
},
currentLibrarySelectionKeys () {
return Object . keys ( this . selectedLibraryIds ). filter ( key => key . startsWith ( ` ${ this . libraryKind } :` ));
2026-05-26 00:19:11 +03:00
},
async bulkLibrary ( action ) {
const count = this . selectedLibraryCount ();
if ( ! count ) return ;
if ( action === 'delete' && ! confirm ( `Delete ${ count } ${ this . libraryKind } ?` )) return ;
2026-05-27 15:56:57 +03:00
const ids = this . currentLibrarySelectionKeys ()
2026-05-26 00:19:11 +03:00
. 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` );
2026-05-27 15:56:57 +03:00
this . clearLibrarySelection ();
2026-05-26 00:19:11 +03:00
await this . loadLibrary ( false );
await this . refreshCountsOnly ();
} catch ( error ) {
this . showToast ( error . message );
}
},
async saveLibraryItem () {
2026-05-27 15:56:57 +03:00
if ( ! this . editorCanSave ()) return ;
this . editorSaving = true ;
2026-05-26 00:19:11 +03:00
try {
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 ,
2026-05-27 15:56:57 +03:00
hidden : this . editorDraft . hidden === 'true' ,
release_type : this . editorDraft . release_type || null ,
year : this . editorDraft . year || '' ,
artist_ids : this . editorDraft . artist_ids || []
2026-05-26 00:19:11 +03:00
})
});
this . replaceLibraryItem ( updated );
this . activeLibraryItem = updated ;
2026-05-27 15:56:57 +03:00
if ( this . editorDetail ) this . editorDetail . item = updated ;
2026-05-26 00:19:11 +03:00
this . showToast ( 'Saved' );
await this . refreshCountsOnly ();
} catch ( error ) {
this . showToast ( error . message );
2026-05-27 15:56:57 +03:00
} finally {
this . editorSaving = false ;
this . icons ();
2026-05-26 00:19:11 +03:00
}
},
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 ) {
this . library . items = this . library . items . map ( item =>
item . kind === updated . kind && item . id === updated . id ? updated : item
);
},
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 );
},
statCells () {
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 : 'Hidden tracks' , value : this . stats . hidden_tracks || 0 },
{ label : 'Hidden releases' , value : this . stats . hidden_releases || 0 },
{ label : 'Hidden artists' , value : this . stats . hidden_artists || 0 }
];
},
pageTitle () {
if ( this . activeView === 'library' ) return 'Library Workbench' ;
if ( this . activeView === 'jobs' ) return 'Tasks' ;
if ( this . activeView === 'tools' ) return 'Future Tools' ;
2026-05-26 18:16:34 +03:00
if ( this . activeView === 'settings' ) return 'Settings' ;
2026-05-26 00:19:11 +03:00
return 'Review Queue' ;
},
pageSubtitle () {
if ( this . activeView === 'library' ) return 'Fast entity control surface for artists, releases, 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' ;
2026-05-26 18:16:34 +03:00
if ( this . activeView === 'settings' ) return 'Application configuration and external API credentials' ;
2026-05-26 00:19:11 +03:00
return 'Full-screen review triage with filter-aware bulk actions' ;
},
reviewPanelSubtitle () {
return ` ${ this . fmt ( this . reviews . total || 0 ) } rows · ${ this . reviewFilter . status || 'all statuses' } ` ;
},
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` ;
},
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 ) } ` ;
},
pagedJobs () {
const start = this . jobsPage * this . jobsPerPage ;
return this . jobs . slice ( start , start + this . jobsPerPage );
},
pageJobs ( delta ) {
const maxPage = Math . max ( 0 , Math . ceil ( this . jobs . length / this . jobsPerPage ) - 1 );
this . jobsPage = Math . min ( maxPage , Math . max ( 0 , this . jobsPage + delta ));
},
jobsRangeText () {
if ( ! this . jobs . length ) return '0 tasks' ;
const start = this . jobsPage * this . jobsPerPage + 1 ;
const end = Math . min (( this . jobsPage + 1 ) * this . jobsPerPage , this . jobs . length );
return ` ${ start } - ${ end } of ${ this . jobs . length } ` ;
},
statusCount ( status ) {
const row = ( this . reviews . status_counts || []). find ( item => item . status === status );
return row ? row . count : 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 ) {
const duration = run . duration_ms || run . duration_ms === 0 ? this . duration ( run . duration_ms ) : this . relativeDate ( run . started_at );
return `# ${ run . id } · ${ duration } ` ;
},
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 %}