2026-04-20 23:16:53 +12:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-05-27 07:21:24 +12:00
< title > EmbyToolkit</ title >
2026-06-08 00:01:55 +12:00
< link rel = "stylesheet" href = "/static/app-theme.css" >
2026-04-20 23:16:53 +12:00
< style >
: root {
--bg : #0d0f12 ;
--surface : #13171f ;
--surface2 : #1a2030 ;
--surface3 : #222840 ;
--border : #252d3d ;
2026-05-27 07:21:24 +12:00
--border-active : #14b8a6 ;
2026-04-20 23:16:53 +12:00
--text : #e8ecf4 ;
--text-2 : #8892a8 ;
--text-3 : #4e5a70 ;
2026-05-27 07:21:24 +12:00
--accent : #14b8a6 ;
--accent-h : #2dd4bf ;
--accent-glow : rgba ( 20 , 184 , 166 , 0.12 );
2026-04-20 23:16:53 +12:00
--green : #34d399 ;
--green-bg : rgba ( 52 , 211 , 153 , 0.08 );
--red : #f87171 ;
--red-bg : rgba ( 248 , 113 , 113 , 0.09 );
--r : 10 px ;
--r-lg : 14 px ;
}
* { margin : 0 ; padding : 0 ; box-sizing : border-box ; }
body {
background : var ( -- bg );
color : var ( -- text );
2026-06-08 00:01:55 +12:00
font-family : 'Inter' , system-ui , sans-serif ;
2026-05-27 07:21:24 +12:00
display : grid ;
grid-template-columns : 220 px 1 fr ;
height : 100 vh ;
height : 100 svh ;
overflow : hidden ;
2026-04-20 23:16:53 +12:00
}
2026-05-27 07:21:24 +12:00
/* ── App Nav Sidebar ── */
. app-nav {
display : flex ;
flex-direction : column ;
background : var ( -- surface );
border-right : 1 px solid var ( -- border );
overflow : hidden ;
}
. app-nav-brand {
padding : 18 px 16 px ;
2026-04-20 23:16:53 +12:00
display : flex ;
align-items : center ;
2026-05-27 07:21:24 +12:00
gap : 10 px ;
2026-04-20 23:16:53 +12:00
border-bottom : 1 px solid var ( -- border );
flex-shrink : 0 ;
}
2026-05-27 07:21:24 +12:00
. app-nav-logo {
width : 32 px ; height : 32 px ;
2026-04-20 23:16:53 +12:00
background : var ( -- accent );
2026-05-27 07:21:24 +12:00
border-radius : 8 px ;
display : grid ; place-items : center ;
2026-04-20 23:16:53 +12:00
color : #fff ;
2026-05-27 07:21:24 +12:00
flex-shrink : 0 ;
2026-04-20 23:16:53 +12:00
}
2026-05-27 07:21:24 +12:00
. app-nav-name {
2026-04-20 23:16:53 +12:00
font-size : 15 px ;
2026-05-27 07:21:24 +12:00
font-weight : 700 ;
letter-spacing : -0.03 em ;
2026-04-20 23:16:53 +12:00
}
2026-05-27 07:21:24 +12:00
. app-nav-items {
list-style : none ;
padding : 8 px ;
flex : 1 ;
overflow-y : auto ;
margin : 0 ;
}
. app-nav-item {
2026-04-20 23:16:53 +12:00
display : flex ;
align-items : center ;
2026-05-27 07:21:24 +12:00
gap : 10 px ;
padding : 9 px 12 px ;
border-radius : 8 px ;
2026-04-20 23:16:53 +12:00
color : var ( -- text -2 );
text-decoration : none ;
2026-05-27 07:21:24 +12:00
font-size : 13 px ;
font-weight : 500 ;
transition : background 0.12 s , color 0.12 s ;
margin-bottom : 1 px ;
2026-04-20 23:16:53 +12:00
}
2026-05-27 07:21:24 +12:00
. app-nav-item : hover { background : var ( -- surface2 ); color : var ( -- text ); }
. app-nav-item . active { background : var ( -- accent ); color : #fff ; }
. app-nav-footer {
padding : 14 px 16 px ;
border-top : 1 px solid var ( -- border );
2026-04-20 23:16:53 +12:00
display : flex ;
align-items : center ;
gap : 7 px ;
font-size : 12 px ;
color : var ( -- text -2 );
2026-05-27 07:21:24 +12:00
flex-shrink : 0 ;
2026-04-20 23:16:53 +12:00
}
. dot {
2026-05-27 07:21:24 +12:00
width : 7 px ; height : 7 px ; border-radius : 50 % ;
background : var ( -- green ); box-shadow : 0 0 6 px var ( -- green );
2026-04-20 23:16:53 +12:00
}
2026-05-27 07:21:24 +12:00
. dot . off { background : var ( -- red ); box-shadow : 0 0 6 px var ( -- red ); }
2026-04-20 23:16:53 +12:00
. body {
min-height : 0 ;
display : grid ;
grid-template-columns : 320 px minmax ( 0 , 1 fr );
}
. sidebar {
display : flex ;
flex-direction : column ;
min-height : 0 ;
border-right : 1 px solid var ( -- border );
background : var ( -- surface );
}
. search-wrap {
padding : 14 px ;
border-bottom : 1 px solid var ( -- border );
}
. search-inner {
position : relative ;
}
. search-inner svg {
position : absolute ;
left : 11 px ;
top : 50 % ;
transform : translateY ( -50 % );
color : var ( -- text -3 );
pointer-events : none ;
}
. search-input {
width : 100 % ;
padding : 9 px 12 px 9 px 36 px ;
background : var ( -- surface2 );
border : 1 px solid var ( -- border );
border-radius : var ( -- r );
color : var ( -- text );
font-family : inherit ;
font-size : 13 px ;
outline : none ;
}
. search-input : focus {
border-color : var ( -- border - active );
box-shadow : 0 0 0 3 px var ( -- accent - glow );
}
. results-toolbar ,
. results-footer {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 10 px ;
padding : 10 px 14 px ;
border-bottom : 1 px solid var ( -- border );
background : var ( -- surface );
flex-shrink : 0 ;
}
. results-footer {
justify-content : flex-end ;
border-top : 1 px solid var ( -- border );
border-bottom : 0 ;
margin-top : auto ;
}
. results-count ,
. results-page {
font-size : 11 px ;
color : var ( -- text -2 );
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-04-20 23:16:53 +12:00
}
. results {
flex : 1 ;
min-height : 0 ;
overflow-y : auto ;
padding : 6 px ;
}
. result-item {
display : flex ;
align-items : center ;
gap : 11 px ;
padding : 9 px 10 px ;
border-radius : 8 px ;
border : 1 px solid transparent ;
cursor : pointer ;
transition : background 0.12 s , border-color 0.12 s ;
}
. result-item : hover {
background : var ( -- surface2 );
}
. result-item . active {
background : var ( -- accent - glow );
border-color : var ( -- accent );
}
. result-poster {
width : 36 px ;
height : 54 px ;
border-radius : 5 px ;
object-fit : cover ;
background : var ( -- surface2 );
flex-shrink : 0 ;
}
. result-info {
min-width : 0 ;
flex : 1 ;
}
. result-name {
font-size : 13 px ;
font-weight : 600 ;
white-space : nowrap ;
overflow : hidden ;
text-overflow : ellipsis ;
}
. result-sub {
display : flex ;
flex-wrap : wrap ;
gap : 6 px ;
margin-top : 2 px ;
font-size : 11 px ;
color : var ( -- text -2 );
}
. tag {
padding : 1 px 6 px ;
border-radius : 999 px ;
background : var ( -- surface3 );
font-size : 10 px ;
font-weight : 700 ;
letter-spacing : 0.05 em ;
text-transform : uppercase ;
}
. empty {
padding : 42 px 20 px ;
text-align : center ;
color : var ( -- text -3 );
font-size : 13 px ;
}
. pager-btn ,
. btn {
min-height : 36 px ;
padding : 0 14 px ;
border-radius : 10 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text );
font-family : inherit ;
font-size : 13 px ;
font-weight : 600 ;
cursor : pointer ;
transition : background 0.12 s , border-color 0.12 s , opacity 0.12 s ;
}
. pager-btn : hover : not ( : disabled ),
. btn : hover : not ( : disabled ) {
background : var ( -- surface3 );
}
. pager-btn : disabled ,
. btn : disabled {
opacity : 0.35 ;
cursor : not-allowed ;
}
. btn-primary {
background : var ( -- accent );
border-color : var ( -- accent );
color : #fff ;
}
. btn-primary : hover : not ( : disabled ) {
background : var ( -- accent - h );
}
. btn-green {
background : var ( -- green );
border-color : var ( -- green );
color : #08120e ;
}
. btn-ghost {
background : transparent ;
}
. main {
min-width : 0 ;
min-height : 0 ;
overflow : auto ;
padding : 20 px 22 px 24 px ;
}
. hero {
display : flex ;
align-items : flex-start ;
justify-content : space-between ;
gap : 16 px ;
margin-bottom : 18 px ;
}
. hero-copy h2 {
margin : 0 0 8 px ;
font-size : clamp ( 24 px , 3 vw , 34 px );
line-height : 1 ;
letter-spacing : -0.04 em ;
}
. hero-copy p {
margin : 0 ;
max-width : 780 px ;
color : var ( -- text -2 );
font-size : 14 px ;
line-height : 1.5 ;
}
. hero-badge {
padding : 8 px 12 px ;
border : 1 px solid var ( -- border );
border-radius : 999 px ;
background : linear-gradient ( 180 deg , rgba ( 26 , 32 , 48 , 0.95 ), rgba ( 19 , 23 , 31 , 0.95 ));
color : var ( -- text -2 );
font-size : 11 px ;
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-04-20 23:16:53 +12:00
white-space : nowrap ;
}
. workspace {
display : grid ;
grid-template-columns : 292 px minmax ( 0 , 1 fr );
gap : 18 px ;
min-height : 0 ;
}
. panel {
border : 1 px solid var ( -- border );
border-radius : var ( -- r - lg );
background : linear-gradient ( 180 deg , rgba ( 19 , 23 , 31 , 0.98 ), rgba ( 13 , 15 , 18 , 0.98 ));
overflow : hidden ;
}
. panel-head {
padding : 14 px 16 px ;
border-bottom : 1 px solid var ( -- border );
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 10 px ;
}
. panel-head h3 {
font-size : 13 px ;
font-weight : 700 ;
letter-spacing : 0.04 em ;
text-transform : uppercase ;
}
. panel-head span {
color : var ( -- text -2 );
font-size : 11 px ;
}
. panel-body {
padding : 16 px ;
}
. collection-meta {
display : flex ;
flex-direction : column ;
gap : 8 px ;
margin-bottom : 14 px ;
}
. collection-name {
font-size : 22 px ;
font-weight : 700 ;
line-height : 1.05 ;
letter-spacing : -0.04 em ;
}
. collection-sub {
display : flex ;
flex-wrap : wrap ;
gap : 8 px ;
color : var ( -- text -2 );
font-size : 12 px ;
}
. asset-grid {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 10 px ;
margin-bottom : 16 px ;
}
. asset-card {
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
border-radius : 12 px ;
padding : 10 px ;
display : flex ;
flex-direction : column ;
gap : 8 px ;
cursor : pointer ;
transition : border-color 0.12 s , background 0.12 s , transform 0.12 s ;
}
. asset-card : hover {
border-color : var ( -- border - active );
}
. asset-card . active {
border-color : var ( -- accent );
background : rgba ( 74 , 111 , 255 , 0.12 );
}
. asset-card-head {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 8 px ;
}
. asset-title {
font-size : 11 px ;
font-weight : 700 ;
letter-spacing : 0.06 em ;
text-transform : uppercase ;
}
. asset-pill {
padding : 3 px 7 px ;
border-radius : 999 px ;
background : rgba ( 52 , 211 , 153 , 0.12 );
color : var ( -- green );
font-size : 10 px ;
font-weight : 700 ;
}
. asset-pill . off {
background : rgba ( 136 , 146 , 168 , 0.14 );
color : var ( -- text -3 );
}
. asset-frame {
position : relative ;
overflow : hidden ;
border-radius : 10 px ;
background : var ( -- bg );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.05 );
}
. asset-frame . thumb {
aspect-ratio : 16 / 9 ;
}
. asset-frame . primary {
aspect-ratio : 2 / 3 ;
}
. asset-frame img {
width : 100 % ;
height : 100 % ;
object-fit : cover ;
display : none ;
}
. asset-empty {
position : absolute ;
inset : 0 ;
display : grid ;
place-items : center ;
text-align : center ;
padding : 12 px ;
color : var ( -- text -3 );
font-size : 11 px ;
}
. dropzone {
border : 1 px dashed var ( -- border );
border-radius : 14 px ;
padding : 14 px ;
background : rgba ( 26 , 32 , 48 , 0.5 );
display : flex ;
flex-direction : column ;
gap : 10 px ;
transition : border-color 0.12 s , background 0.12 s ;
}
. dropzone . drag-over {
border-color : var ( -- accent );
background : rgba ( 74 , 111 , 255 , 0.1 );
}
. dropzone-copy {
display : flex ;
flex-direction : column ;
gap : 5 px ;
}
. dropzone-title {
font-size : 13 px ;
font-weight : 700 ;
}
. dropzone-copy p ,
. source-status {
color : var ( -- text -2 );
font-size : 12 px ;
line-height : 1.4 ;
}
. dropzone-actions {
display : flex ;
flex-wrap : wrap ;
gap : 8 px ;
}
. source-status strong {
color : var ( -- text );
}
. editor {
display : flex ;
flex-direction : column ;
gap : 18 px ;
min-width : 0 ;
}
. preview-panel {
padding : 18 px ;
}
. preview-top {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
margin-bottom : 14 px ;
}
. preview-title {
display : flex ;
flex-direction : column ;
gap : 5 px ;
}
. preview-title strong {
font-size : 16 px ;
letter-spacing : -0.03 em ;
}
. preview-title span {
color : var ( -- text -2 );
font-size : 12 px ;
}
. preview-meta {
padding : 8 px 10 px ;
border : 1 px solid var ( -- border );
border-radius : 999 px ;
background : var ( -- surface2 );
color : var ( -- text -2 );
font-size : 11 px ;
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-04-20 23:16:53 +12:00
white-space : nowrap ;
}
. preview-shell {
display : grid ;
place-items : center ;
min-height : 320 px ;
border : 1 px solid var ( -- border );
border-radius : 18 px ;
background :
radial-gradient ( circle at top right , rgba ( 74 , 111 , 255 , 0.18 ), transparent 34 % ),
linear-gradient ( 180 deg , rgba ( 26 , 32 , 48 , 0.7 ), rgba ( 13 , 15 , 18 , 0.92 ));
padding : 20 px ;
}
. preview-placeholder {
color : var ( -- text -3 );
text-align : center ;
display : flex ;
flex-direction : column ;
gap : 10 px ;
align-items : center ;
justify-content : center ;
}
. preview-frame {
position : relative ;
width : min ( 100 % , 860 px );
aspect-ratio : 16 / 9 ;
border-radius : 18 px ;
overflow : hidden ;
background : var ( -- surface2 );
box-shadow : 0 10 px 40 px rgba ( 0 , 0 , 0 , 0.45 );
display : none ;
cursor : grab ;
touch-action : none ;
}
. preview-frame . primary {
width : min ( 100 % , 360 px );
aspect-ratio : 2 / 3 ;
}
. preview-frame . visible {
display : block ;
}
. preview-frame . dragging {
cursor : grabbing ;
}
. preview-frame img {
width : 100 % ;
height : 100 % ;
object-fit : cover ;
display : block ;
}
. preview-tools ,
. preview-zoom {
position : absolute ;
z-index : 2 ;
display : flex ;
align-items : center ;
gap : 8 px ;
}
. preview-tools {
top : 12 px ;
left : 12 px ;
padding : 7 px 10 px ;
border-radius : 999 px ;
background : rgba ( 0 , 0 , 0 , 0.56 );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 );
backdrop-filter : blur ( 8 px );
}
. preview-zoom {
top : 12 px ;
right : 12 px ;
}
. mini-link {
border : 0 ;
background : transparent ;
color : var ( -- accent - h );
font : inherit ;
font-size : 11 px ;
font-weight : 700 ;
cursor : pointer ;
padding : 0 ;
}
. mini-link : hover {
color : #8fa4ff ;
}
. preview-zoom-btn {
width : 32 px ;
height : 32 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.14 );
border-radius : 999 px ;
background : rgba ( 0 , 0 , 0 , 0.58 );
color : rgba ( 255 , 255 , 255 , 0.92 );
font : inherit ;
font-size : 18 px ;
line-height : 1 ;
cursor : pointer ;
backdrop-filter : blur ( 8 px );
}
. preview-zoom-btn : hover {
background : rgba ( 0 , 0 , 0 , 0.72 );
}
. preview-hint {
position : absolute ;
left : 50 % ;
bottom : 12 px ;
transform : translateX ( -50 % );
padding : 7 px 10 px ;
border-radius : 999 px ;
background : rgba ( 0 , 0 , 0 , 0.58 );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 );
color : rgba ( 255 , 255 , 255 , 0.88 );
font-size : 11 px ;
font-weight : 600 ;
pointer-events : none ;
backdrop-filter : blur ( 8 px );
white-space : nowrap ;
}
. loading-cover {
position : absolute ;
inset : 0 ;
display : none ;
align-items : center ;
justify-content : center ;
background : rgba ( 13 , 15 , 18 , 0.82 );
z-index : 3 ;
}
. loading-cover . on {
display : flex ;
}
. controls-panel {
padding : 16 px 18 px 18 px ;
}
. control-grid {
display : grid ;
grid-template-columns : repeat ( 12 , minmax ( 0 , 1 fr ));
gap : 14 px ;
}
. control-group {
display : flex ;
flex-direction : column ;
gap : 6 px ;
}
. control-group label {
font-size : 10 px ;
font-weight : 700 ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
color : var ( -- text -2 );
}
. span-12 { grid-column : span 12 ; }
. span-6 { grid-column : span 6 ; }
. span-4 { grid-column : span 4 ; }
. span-3 { grid-column : span 3 ; }
. ctrl-input ,
. ctrl-textarea ,
. ctrl-select {
width : 100 % ;
min-height : 38 px ;
padding : 9 px 11 px ;
border-radius : 10 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text );
font-family : inherit ;
font-size : 13 px ;
outline : none ;
}
. ctrl-input : focus ,
. ctrl-textarea : focus ,
. ctrl-select : focus {
border-color : var ( -- border - active );
box-shadow : 0 0 0 3 px var ( -- accent - glow );
}
. ctrl-textarea {
min-height : 94 px ;
resize : vertical ;
line-height : 1.45 ;
}
. ctrl-select option {
background : var ( -- surface );
}
. picker-row {
display : flex ;
gap : 8 px ;
align-items : center ;
}
. picker-row input [ type = "color" ] {
width : 42 px ;
height : 38 px ;
border-radius : 10 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
cursor : pointer ;
padding : 2 px ;
}
. picker-row input [ type = "text" ] {
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-04-20 23:16:53 +12:00
}
. segmented {
display : flex ;
gap : 8 px ;
flex-wrap : wrap ;
}
. seg-btn {
min-height : 38 px ;
padding : 0 12 px ;
border-radius : 10 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text -2 );
font-family : inherit ;
font-size : 12 px ;
font-weight : 700 ;
cursor : pointer ;
transition : background 0.12 s , border-color 0.12 s , color 0.12 s ;
}
. seg-btn : hover {
background : var ( -- surface3 );
}
. seg-btn . active {
background : var ( -- accent - glow );
border-color : var ( -- accent );
color : var ( -- text );
}
. slider-wrap {
display : flex ;
align-items : center ;
gap : 8 px ;
}
. slider-step {
width : 28 px ;
height : 28 px ;
border-radius : 8 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text );
font-size : 15 px ;
font-weight : 700 ;
line-height : 1 ;
display : grid ;
place-items : center ;
cursor : pointer ;
flex-shrink : 0 ;
}
. slider-step : hover {
background : var ( -- surface3 );
border-color : var ( -- border - active );
}
. slider-wrap input [ type = "range" ] {
width : 100 % ;
accent-color : var ( -- accent );
cursor : pointer ;
}
. slider-val {
min-width : 44 px ;
text-align : right ;
color : var ( -- text -2 );
font-size : 11 px ;
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-04-20 23:16:53 +12:00
}
. actions {
display : flex ;
justify-content : flex-end ;
gap : 10 px ;
margin-top : 18 px ;
flex-wrap : wrap ;
}
. spinner {
width : 32 px ;
height : 32 px ;
border : 2.5 px solid var ( -- border );
border-top-color : var ( -- accent );
border-radius : 50 % ;
animation : spin 0.7 s linear infinite ;
}
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
. toast {
position : fixed ;
bottom : 20 px ;
right : 20 px ;
padding : 11 px 18 px ;
border-radius : var ( -- r );
font-size : 13 px ;
font-weight : 500 ;
z-index : 200 ;
transform : translateY ( 80 px );
opacity : 0 ;
transition : all 0.25 s cubic-bezier ( 0.4 , 0 , 0.2 , 1 );
pointer-events : none ;
}
. toast . show {
transform : translateY ( 0 );
opacity : 1 ;
}
. toast . ok {
background : var ( -- green - bg );
border : 1 px solid var ( -- green );
color : var ( -- green );
}
. toast . err {
background : var ( -- red - bg );
border : 1 px solid var ( -- red );
color : var ( -- red );
}
@ media ( max-width : 1280px ) {
. workspace {
grid-template-columns : 260 px minmax ( 0 , 1 fr );
}
. control-grid {
grid-template-columns : repeat ( 8 , minmax ( 0 , 1 fr ));
}
. span-6 { grid-column : span 8 ; }
. span-4 { grid-column : span 4 ; }
. span-3 { grid-column : span 4 ; }
}
@ media ( max-width : 980px ) {
. body {
grid-template-columns : 1 fr ;
grid-template-rows : minmax ( 220 px , 34 vh ) minmax ( 0 , 1 fr );
}
. sidebar {
border-right : 0 ;
border-bottom : 1 px solid var ( -- border );
}
. workspace {
grid-template-columns : 1 fr ;
}
. hero {
flex-direction : column ;
}
}
@ media ( max-width : 720px ) {
. header {
padding : 0 14 px ;
gap : 10 px ;
}
. header-nav {
gap : 6 px ;
margin-left : 2 px ;
}
. nav-link {
padding : 6 px 8 px ;
}
. main {
padding : 16 px 14 px 20 px ;
}
. control-grid {
grid-template-columns : repeat ( 1 , minmax ( 0 , 1 fr ));
}
. span-12 ,
. span-6 ,
. span-4 ,
. span-3 {
grid-column : span 1 ;
}
. actions {
justify-content : stretch ;
}
. actions . btn {
width : 100 % ;
}
}
</ style >
</ head >
< body >
2026-05-27 07:21:24 +12:00
< nav class = "app-nav" >
< div class = "app-nav-brand" >
< div class = "app-nav-logo" >
< svg width = "18" height = "18" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />< polyline points = "3.29 7 12 12 20.71 7" />< line x1 = "12" y1 = "22" x2 = "12" y2 = "12" /></ svg >
</ div >
< span class = "app-nav-name" > EmbyToolkit</ span >
</ div >
< ul class = "app-nav-items" >
< li >< a class = "app-nav-item" href = "/" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "3" y = "3" width = "18" height = "18" rx = "2" />< circle cx = "8.5" cy = "8.5" r = "1.5" />< path d = "m21 15-5-5L5 21" /></ svg >
< span > Generator</ span >
</ a ></ li >
< li >< a class = "app-nav-item active" href = "/collections" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "3" y = "3" width = "7" height = "7" />< rect x = "14" y = "3" width = "7" height = "7" />< rect x = "14" y = "14" width = "7" height = "7" />< rect x = "3" y = "14" width = "7" height = "7" /></ svg >
< span > Collections</ span >
</ a ></ li >
< li >< a class = "app-nav-item" href = "/airing" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "7" width = "20" height = "15" rx = "2" />< polyline points = "17 2 12 7 7 2" /></ svg >
< span > Airing</ span >
</ a ></ li >
< li >< a class = "app-nav-item" href = "/bulk-assign" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></ svg >
< span > Bulk Assign</ span >
</ a ></ li >
2026-06-08 00:01:55 +12:00
< li >< a class = "app-nav-item" href = "/favorites" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></ svg >
< span > User Favourites</ span >
</ a ></ li >
2026-05-27 07:21:24 +12:00
</ ul >
2026-06-08 00:01:55 +12:00
< div class = "app-nav-foot" >
< div class = "app-nav-status" >
< span class = "dot" id = "statusDot" ></ span >
< span id = "statusText" > Checking…</ span >
</ div >
< div class = "app-nav-version" > EmbyToolkit · v1.0</ div >
2026-04-20 23:16:53 +12:00
</ div >
2026-05-27 07:21:24 +12:00
</ nav >
2026-04-20 23:16:53 +12:00
< div class = "body" >
< aside class = "sidebar" >
< div class = "search-wrap" >
< div class = "search-inner" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" >< circle cx = "11" cy = "11" r = "8" />< line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" /></ svg >
< input class = "search-input" id = "searchInput" type = "text" placeholder = "Search collections…" autocomplete = "off" >
</ div >
</ div >
< div class = "results-toolbar" >
< div class = "results-count" id = "resultsCount" > Loading…</ div >
< div class = "results-page" id = "resultsPage" ></ div >
</ div >
< div class = "results" id = "resultsList" >
< div class = "empty" >< div class = "spinner" style = "margin:0 auto 10px" ></ div > Loading collections…</ div >
</ div >
< div class = "results-footer" >
< button class = "pager-btn" id = "btnPrevPage" disabled > Previous</ button >
< button class = "pager-btn" id = "btnNextPage" disabled > Next</ button >
</ div >
</ aside >
< main class = "main" >
< section class = "hero" >
< div class = "hero-copy" >
< h2 > Collection Artwork</ h2 >
< p > Select an Emby collection, switch between `Thumb` and `Primary`, then use either the current artwork or a dropped image as the base. The editor lets you crop, darken, and overlay custom text before pushing the result back to Emby.</ p >
</ div >
< div class = "hero-badge" id = "sourceBadge" > Waiting for collection</ div >
</ section >
< section class = "workspace" >
< div class = "panel" >
< div class = "panel-head" >
< h3 > Collection</ h3 >
< span id = "selectedTargetNote" > Editing Thumb</ span >
</ div >
< div class = "panel-body" >
< div class = "collection-meta" >
< div class = "collection-name" id = "collectionName" > Pick a collection</ div >
< div class = "collection-sub" id = "collectionMeta" >
< span class = "tag" > BoxSet</ span >
< span > No selection yet</ span >
</ div >
</ div >
< div class = "asset-grid" >
< button class = "asset-card active" id = "thumbCard" type = "button" data-target = "Thumb" >
< div class = "asset-card-head" >
< span class = "asset-title" > Thumb</ span >
< span class = "asset-pill off" id = "thumbState" > Missing</ span >
</ div >
< div class = "asset-frame thumb" >
< img id = "thumbCurrentImg" alt = "" >
< div class = "asset-empty" id = "thumbEmpty" > No thumb artwork yet</ div >
</ div >
</ button >
< button class = "asset-card" id = "primaryCard" type = "button" data-target = "Primary" >
< div class = "asset-card-head" >
< span class = "asset-title" > Primary</ span >
< span class = "asset-pill off" id = "primaryState" > Missing</ span >
</ div >
< div class = "asset-frame primary" >
< img id = "primaryCurrentImg" alt = "" >
< div class = "asset-empty" id = "primaryEmpty" > No primary artwork yet</ div >
</ div >
</ button >
</ div >
< div class = "dropzone" id = "dropzone" >
< div class = "dropzone-copy" >
< div class = "dropzone-title" > Upload or drop a base image</ div >
< p > Drop a JPG, PNG, or WEBP here to use it as the background for the current target. If you clear it, the editor falls back to the collection’ s existing artwork.</ p >
</ div >
< div class = "source-status" id = "sourceStatus" > Using current artwork when available.</ div >
< div class = "dropzone-actions" >
< button class = "btn btn-primary" id = "btnChooseImage" type = "button" > Choose image</ button >
< button class = "btn btn-ghost" id = "btnClearImage" type = "button" disabled > Clear upload</ button >
</ div >
< input id = "uploadInput" type = "file" accept = "image/*" hidden >
</ div >
</ div >
</ div >
< div class = "editor" >
< div class = "panel preview-panel" >
< div class = "preview-top" >
< div class = "preview-title" >
< strong id = "previewHeading" > Preview</ strong >
< span id = "previewSub" > Select a collection to start editing.</ span >
</ div >
< div class = "preview-meta" id = "previewMeta" > 100% · X 0 · Y 0</ div >
</ div >
< div class = "preview-shell" >
< div class = "preview-placeholder" id = "previewPlaceholder" >
< svg width = "46" height = "46" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.1" >< rect x = "3" y = "3" width = "18" height = "18" rx = "2" />< path d = "m8 8 8 8m0-8-8 8" /></ svg >
< div > Select a collection from the left to build custom artwork.</ div >
</ div >
< div class = "preview-frame" id = "previewFrame" title = "Drag to reposition the crop. Use the mouse wheel to zoom." >
< img id = "previewImg" alt = "" >
< div class = "preview-tools" >
< button class = "mini-link" id = "btnResetCrop" type = "button" > Reset</ button >
</ div >
< div class = "preview-zoom" >
< button class = "preview-zoom-btn" id = "btnPreviewZoomOut" type = "button" aria-label = "Zoom out" > -</ button >
< button class = "preview-zoom-btn" id = "btnPreviewZoomIn" type = "button" aria-label = "Zoom in" > +</ button >
</ div >
< div class = "preview-hint" > Drag to position • Wheel to zoom</ div >
< div class = "loading-cover" id = "loadingCover" >< div class = "spinner" ></ div ></ div >
</ div >
</ div >
</ div >
< div class = "panel controls-panel" >
< div class = "control-grid" >
< div class = "control-group span-12" >
< label for = "overlayText" > Overlay Text</ label >
< textarea class = "ctrl-textarea" id = "overlayText" placeholder = "Collection title" ></ textarea >
</ div >
< div class = "control-group span-4" >
< label > Artwork Target</ label >
< div class = "segmented" id = "targetButtons" >
< button class = "seg-btn active" type = "button" data-target = "Thumb" > Thumb</ button >
< button class = "seg-btn" type = "button" data-target = "Primary" > Primary</ button >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Text Alignment</ label >
< div class = "segmented" id = "alignButtons" >
< button class = "seg-btn" type = "button" data-align = "left" > Left</ button >
< button class = "seg-btn active" type = "button" data-align = "center" > Center</ button >
< button class = "seg-btn" type = "button" data-align = "right" > Right</ button >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Text Position</ label >
< div class = "segmented" id = "positionButtons" >
< button class = "seg-btn" type = "button" data-position = "top" > Top</ button >
< button class = "seg-btn" type = "button" data-position = "center" > Center</ button >
< button class = "seg-btn active" type = "button" data-position = "bottom" > Bottom</ button >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Text Colour</ label >
< div class = "picker-row" >
< input id = "textColorPicker" type = "color" value = "#ffffff" >
< input class = "ctrl-input" id = "textColorText" type = "text" value = "#FFFFFF" maxlength = "7" >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Text Size</ label >
< div class = "slider-wrap" >
< button class = "slider-step" id = "btnTextScaleDown" type = "button" > -</ button >
< input id = "textScaleSlider" type = "range" min = "70" max = "180" value = "100" >
< button class = "slider-step" id = "btnTextScaleUp" type = "button" > +</ button >
< span class = "slider-val" id = "textScaleVal" > 100%</ span >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Darkness</ label >
< div class = "slider-wrap" >
< input id = "darknessSlider" type = "range" min = "0" max = "85" value = "18" >
< span class = "slider-val" id = "darknessVal" > 18%</ span >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Zoom</ label >
< div class = "slider-wrap" >
< button class = "slider-step" id = "btnZoomOut" type = "button" > -</ button >
< input id = "zoomSlider" type = "range" min = "100" max = "260" value = "100" >
< button class = "slider-step" id = "btnZoomIn" type = "button" > +</ button >
< span class = "slider-val" id = "zoomVal" > 100%</ span >
</ div >
</ div >
< div class = "control-group span-4" >
< label > X Position</ label >
< div class = "slider-wrap" >
< input id = "panXSlider" type = "range" min = "-100" max = "100" value = "0" >
< span class = "slider-val" id = "panXVal" > 0</ span >
</ div >
</ div >
< div class = "control-group span-4" >
< label > Y Position</ label >
< div class = "slider-wrap" >
< input id = "panYSlider" type = "range" min = "-100" max = "100" value = "0" >
< span class = "slider-val" id = "panYVal" > 0</ span >
</ div >
</ div >
</ div >
< div class = "actions" >
< button class = "btn btn-ghost" id = "btnRegenerate" type = "button" disabled > Regenerate</ button >
< button class = "btn btn-primary" id = "btnGenerate" type = "button" disabled > Generate</ button >
< button class = "btn btn-green" id = "btnApply" type = "button" disabled > Apply Thumb to Emby</ button >
</ div >
</ div >
</ div >
</ section >
</ main >
</ div >
< div class = "toast" id = "toast" ></ div >
< script >
const state = {
item : null ,
targetType : 'Thumb' ,
artwork : {
thumb : { exists : false , url : null },
primary : { exists : false , url : null },
},
uploadBgId : null ,
uploadFileName : '' ,
generated : false ,
cacheKey : null ,
searchQuery : '' ,
searchStart : 0 ,
searchLimit : 24 ,
searchTotal : 0 ,
searchController : null ,
};
const $ = selector => document . querySelector ( selector );
const searchInput = $ ( '#searchInput' );
const resultsList = $ ( '#resultsList' );
const resultsCount = $ ( '#resultsCount' );
const resultsPage = $ ( '#resultsPage' );
const btnPrevPage = $ ( '#btnPrevPage' );
const btnNextPage = $ ( '#btnNextPage' );
const collectionName = $ ( '#collectionName' );
const collectionMeta = $ ( '#collectionMeta' );
const thumbCard = $ ( '#thumbCard' );
const primaryCard = $ ( '#primaryCard' );
const thumbCurrentImg = $ ( '#thumbCurrentImg' );
const primaryCurrentImg = $ ( '#primaryCurrentImg' );
const thumbEmpty = $ ( '#thumbEmpty' );
const primaryEmpty = $ ( '#primaryEmpty' );
const thumbState = $ ( '#thumbState' );
const primaryState = $ ( '#primaryState' );
const selectedTargetNote = $ ( '#selectedTargetNote' );
const dropzone = $ ( '#dropzone' );
const sourceStatus = $ ( '#sourceStatus' );
const sourceBadge = $ ( '#sourceBadge' );
const uploadInput = $ ( '#uploadInput' );
const btnChooseImage = $ ( '#btnChooseImage' );
const btnClearImage = $ ( '#btnClearImage' );
const previewHeading = $ ( '#previewHeading' );
const previewSub = $ ( '#previewSub' );
const previewMeta = $ ( '#previewMeta' );
const previewPlaceholder = $ ( '#previewPlaceholder' );
const previewFrame = $ ( '#previewFrame' );
const previewImg = $ ( '#previewImg' );
const loadingCover = $ ( '#loadingCover' );
const overlayText = $ ( '#overlayText' );
const targetButtons = $ ( '#targetButtons' );
const alignButtons = $ ( '#alignButtons' );
const positionButtons = $ ( '#positionButtons' );
const textColorPicker = $ ( '#textColorPicker' );
const textColorText = $ ( '#textColorText' );
const textScaleSlider = $ ( '#textScaleSlider' );
const textScaleVal = $ ( '#textScaleVal' );
const darknessSlider = $ ( '#darknessSlider' );
const darknessVal = $ ( '#darknessVal' );
const zoomSlider = $ ( '#zoomSlider' );
const zoomVal = $ ( '#zoomVal' );
const panXSlider = $ ( '#panXSlider' );
const panXVal = $ ( '#panXVal' );
const panYSlider = $ ( '#panYSlider' );
const panYVal = $ ( '#panYVal' );
const btnTextScaleDown = $ ( '#btnTextScaleDown' );
const btnTextScaleUp = $ ( '#btnTextScaleUp' );
const btnZoomOut = $ ( '#btnZoomOut' );
const btnZoomIn = $ ( '#btnZoomIn' );
const btnPreviewZoomOut = $ ( '#btnPreviewZoomOut' );
const btnPreviewZoomIn = $ ( '#btnPreviewZoomIn' );
const btnResetCrop = $ ( '#btnResetCrop' );
const btnGenerate = $ ( '#btnGenerate' );
const btnRegenerate = $ ( '#btnRegenerate' );
const btnApply = $ ( '#btnApply' );
const toast = $ ( '#toast' );
let searchTimer ;
let previewTimer ;
let generateController = null ;
let generateRequestId = 0 ;
let dragState = null ;
let textAlign = 'center' ;
let textPosition = 'bottom' ;
function esc ( value ) {
const div = document . createElement ( 'div' );
div . textContent = value ;
return div . innerHTML ;
}
function showToast ( message , type ) {
toast . textContent = message ;
toast . className = `toast ${ type } show` ;
setTimeout (() => toast . classList . remove ( 'show' ), 2800 );
}
function clamp ( value , min , max ) {
return Math . max ( min , Math . min ( max , value ));
}
function updatePreviewMeta () {
zoomVal . textContent = ` ${ zoomSlider . value } %` ;
panXVal . textContent = panXSlider . value ;
panYVal . textContent = panYSlider . value ;
textScaleVal . textContent = ` ${ textScaleSlider . value } %` ;
darknessVal . textContent = ` ${ darknessSlider . value } %` ;
previewMeta . textContent = ` ${ zoomSlider . value } % · X ${ panXSlider . value } · Y ${ panYSlider . value } ` ;
}
function getSelectedTextAlign () {
return textAlign ;
}
function getSelectedTextPosition () {
return textPosition ;
}
function getCurrentSourceLabel () {
if ( state . uploadBgId ) {
return `Using uploaded image ${ state . uploadFileName ? `: ${ state . uploadFileName } ` : '' } ` ;
}
const target = state . targetType . toLowerCase ();
const targetExists = state . targetType === 'Thumb' ? state . artwork . thumb . exists : state . artwork . primary . exists ;
const fallbackExists = state . targetType === 'Thumb' ? state . artwork . primary . exists : state . artwork . thumb . exists ;
if ( targetExists ) {
return `Using current ${ target } artwork as the base` ;
}
if ( fallbackExists ) {
return `Using current ${ state . targetType === 'Thumb' ? 'primary' : 'thumb' } artwork as the base` ;
}
return 'No existing artwork found. Preview uses a generated fallback background until you upload an image.' ;
}
function updateSourceUi () {
sourceStatus . innerHTML = `<strong>Source:</strong> ${ esc ( getCurrentSourceLabel ()) } ` ;
sourceBadge . textContent = state . item ? getCurrentSourceLabel () : 'Waiting for collection' ;
btnClearImage . disabled = ! state . uploadBgId ;
}
function updateApplyButton () {
btnApply . textContent = state . targetType === 'Primary' ? 'Apply Primary to Emby' : 'Apply Thumb to Emby' ;
selectedTargetNote . textContent = `Editing ${ state . targetType } ` ;
}
function updateTargetUi () {
thumbCard . classList . toggle ( 'active' , state . targetType === 'Thumb' );
primaryCard . classList . toggle ( 'active' , state . targetType === 'Primary' );
targetButtons . querySelectorAll ( '[data-target]' ). forEach ( button => {
button . classList . toggle ( 'active' , button . dataset . target === state . targetType );
});
previewFrame . classList . toggle ( 'primary' , state . targetType === 'Primary' );
updateApplyButton ();
updateSourceUi ();
}
function updateArtworkCard ( kind , payload ) {
const isThumb = kind === 'thumb' ;
const img = isThumb ? thumbCurrentImg : primaryCurrentImg ;
const empty = isThumb ? thumbEmpty : primaryEmpty ;
const pill = isThumb ? thumbState : primaryState ;
const frame = img . closest ( '.asset-frame' );
frame . classList . toggle ( 'thumb' , isThumb );
frame . classList . toggle ( 'primary' , ! isThumb );
if ( payload . exists && payload . url ) {
img . src = ` ${ payload . url } &t= ${ Date . now () } ` ;
img . style . display = 'block' ;
empty . style . display = 'none' ;
pill . textContent = 'Exists' ;
pill . classList . remove ( 'off' );
} else {
img . removeAttribute ( 'src' );
img . style . display = 'none' ;
empty . style . display = '' ;
pill . textContent = 'Missing' ;
pill . classList . add ( 'off' );
}
}
function updateArtworkUi () {
updateArtworkCard ( 'thumb' , state . artwork . thumb );
updateArtworkCard ( 'primary' , state . artwork . primary );
updateSourceUi ();
}
function updateSelectionUi () {
if ( ! state . item ) {
collectionName . textContent = 'Pick a collection' ;
collectionMeta . innerHTML = '<span class="tag">BoxSet</span><span>No selection yet</span>' ;
previewHeading . textContent = 'Preview' ;
previewSub . textContent = 'Select a collection to start editing.' ;
btnGenerate . disabled = true ;
btnRegenerate . disabled = true ;
btnApply . disabled = true ;
updateTargetUi ();
return ;
}
collectionName . textContent = state . item . name ;
collectionMeta . innerHTML = `
<span class="tag"> ${ esc ( state . item . type || 'BoxSet' ) } </span>
<span> ${ state . item . child_count || 0 } item ${ state . item . child_count === 1 ? '' : 's' } </span>
` ;
previewHeading . textContent = ` ${ state . item . name } · ${ state . targetType } ` ;
previewSub . textContent = 'Drag to adjust the crop, then apply the generated artwork back to Emby.' ;
btnGenerate . disabled = false ;
btnRegenerate . disabled = ! state . generated ;
btnApply . disabled = ! state . generated ;
updateTargetUi ();
}
function setGenerated ( value ) {
state . generated = value ;
btnRegenerate . disabled = ! value || ! state . item ;
btnApply . disabled = ! value || ! state . item ;
}
async function checkConnection () {
try {
const response = await fetch ( '/api/config' );
const data = await response . json ();
if ( data . connected ) {
$ ( '#statusText' ). textContent = 'Connected to Emby' ;
} else {
$ ( '#statusDot' ). classList . add ( 'off' );
$ ( '#statusText' ). textContent = 'No API key' ;
}
} catch {
$ ( '#statusDot' ). classList . add ( 'off' );
$ ( '#statusText' ). textContent = 'Connection error' ;
}
}
function updatePager ( total , start , limit ) {
resultsCount . textContent = total
? ` ${ Math . min ( start + 1 , total ) } - ${ Math . min ( start + limit , total ) } of ${ total } `
: '0 results' ;
resultsPage . textContent = total ? `Page ${ Math . floor ( start / limit ) + 1 } ` : '' ;
btnPrevPage . disabled = start === 0 ;
btnNextPage . disabled = start + limit >= total ;
}
async function loadCollections ( query = '' , start = 0 ) {
if ( state . searchController ) state . searchController . abort ();
const controller = new AbortController ();
state . searchController = controller ;
state . searchQuery = query ;
state . searchStart = start ;
resultsList . innerHTML = '<div class="empty"><div class="spinner" style="margin:0 auto 10px"></div>Loading collections…</div>' ;
try {
const response = await fetch ( `/api/collections?q= ${ encodeURIComponent ( query ) } &start= ${ start } &limit= ${ state . searchLimit } ` , {
signal : controller . signal ,
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
if ( state . searchController !== controller ) return ;
state . searchTotal = data . total ;
state . searchStart = data . start ;
if ( ! data . items . length ) {
resultsList . innerHTML = `<div class="empty"> ${ query ? 'No matching collections.' : 'No collections found.' } </div>` ;
updatePager ( 0 , 0 , state . searchLimit );
return ;
}
resultsList . innerHTML = data . items . map ( item => `
<div class="result-item ${ state . item ? . id === item . id ? 'active' : '' } " data-item=' ${ JSON . stringify ( item ). replace ( /'/g , ''' ) } '>
${ item . poster_url
? `<img class="result-poster" src=" ${ item . poster_url } " alt="" loading="lazy" decoding="async" width="36" height="54">`
: '<div class="result-poster" aria-hidden="true"></div>' }
<div class="result-info">
<div class="result-name"> ${ esc ( item . name ) } </div>
<div class="result-sub">
<span class="tag">Collection</span>
<span> ${ item . child_count || 0 } item ${ item . child_count === 1 ? '' : 's' } </span>
</div>
</div>
</div>
` ). join ( '' );
updatePager ( data . total , data . start , data . limit );
resultsList . querySelectorAll ( '.result-item' ). forEach ( element => {
element . addEventListener ( 'click' , () => selectCollection ( JSON . parse ( element . dataset . item )));
});
} catch ( error ) {
if ( error . name === 'AbortError' ) return ;
resultsList . innerHTML = `<div class="empty">Failed to load collections: ${ esc ( error . message ) } </div>` ;
updatePager ( 0 , 0 , state . searchLimit );
} finally {
if ( state . searchController === controller ) state . searchController = null ;
}
}
async function refreshArtwork () {
if ( ! state . item ) return ;
try {
const response = await fetch ( `/api/collections/ ${ state . item . id } /artwork` );
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
state . artwork = {
thumb : data . thumb ?? { exists : false , url : null },
primary : data . primary ?? { exists : false , url : null },
};
updateArtworkUi ();
} catch {
state . artwork = {
thumb : { exists : false , url : null },
primary : { exists : false , url : null },
};
updateArtworkUi ();
}
}
function schedulePreview () {
if ( ! state . item ) return ;
clearTimeout ( previewTimer );
setGenerated ( false );
previewTimer = setTimeout (() => generatePreview ({ silent : true }), 180 );
}
function setCropValues ({ zoom , panX , panY }, { regenerate = false } = {}) {
if ( zoom != null ) zoomSlider . value = String ( clamp ( Math . round ( zoom ), 100 , 260 ));
if ( panX != null ) panXSlider . value = String ( clamp ( Math . round ( panX ), - 100 , 100 ));
if ( panY != null ) panYSlider . value = String ( clamp ( Math . round ( panY ), - 100 , 100 ));
updatePreviewMeta ();
if ( regenerate ) schedulePreview ();
}
function setTextScale ( value , { regenerate = false } = {}) {
textScaleSlider . value = String ( clamp ( Math . round ( value ), 70 , 180 ));
updatePreviewMeta ();
if ( regenerate ) schedulePreview ();
}
function setTarget ( targetType ) {
if ( ! targetType || targetType === state . targetType ) return ;
state . targetType = targetType ;
state . cacheKey = null ;
setGenerated ( false );
updateSelectionUi ();
updateSourceUi ();
if ( state . item ) generatePreview ({ silent : true });
}
async function selectCollection ( item ) {
if ( state . item ? . id === item . id ) return ;
state . item = item ;
state . artwork = {
thumb : { exists : false , url : null },
primary : { exists : false , url : null },
};
state . uploadBgId = null ;
state . uploadFileName = '' ;
state . cacheKey = null ;
previewPlaceholder . style . display = 'none' ;
previewFrame . classList . add ( 'visible' );
overlayText . value = item . name ;
setCropValues ({ zoom : 100 , panX : 0 , panY : 0 });
setTextScale ( 100 );
darknessSlider . value = '18' ;
textColorText . value = '#FFFFFF' ;
textColorPicker . value = '#ffffff' ;
updatePreviewMeta ();
setGenerated ( false );
updateSelectionUi ();
updateArtworkUi ();
resultsList . querySelectorAll ( '.result-item' ). forEach ( element => element . classList . remove ( 'active' ));
resultsList . querySelector ( `[data-item*='"id":" ${ item . id } "']` ) ? . classList . add ( 'active' );
generatePreview ({ silent : true });
refreshArtwork ();
}
async function uploadFile ( file ) {
if ( ! file ) return ;
const formData = new FormData ();
formData . append ( 'file' , file );
btnChooseImage . disabled = true ;
try {
const response = await fetch ( '/api/upload-background' , {
method : 'POST' ,
body : formData ,
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
state . uploadBgId = data . upload_id ;
state . uploadFileName = file . name ;
updateSourceUi ();
showToast ( `Image ready ( ${ data . width } × ${ data . height } )` , 'ok' );
if ( state . item ) generatePreview ({ silent : true });
} catch ( error ) {
state . uploadBgId = null ;
state . uploadFileName = '' ;
updateSourceUi ();
showToast ( `Upload failed: ${ error . message } ` , 'err' );
} finally {
btnChooseImage . disabled = false ;
}
}
async function generatePreview ({ silent = false } = {}) {
if ( ! state . item ) return ;
clearTimeout ( previewTimer );
if ( generateController ) generateController . abort ();
const controller = new AbortController ();
generateController = controller ;
const requestId = ++ generateRequestId ;
loadingCover . classList . add ( 'on' );
btnGenerate . disabled = true ;
try {
const response = await fetch ( '/api/collections/generate' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
signal : controller . signal ,
body : JSON . stringify ({
item_id : state . item . id ,
title : state . item . name ,
target_type : state . targetType ,
text : overlayText . value . trim () || state . item . name ,
text_color : textColorText . value ,
text_align : getSelectedTextAlign (),
text_position : getSelectedTextPosition (),
text_scale : Number ( textScaleSlider . value ) / 100 ,
darkness : Number ( darknessSlider . value ) / 100 ,
zoom : Number ( zoomSlider . value ) / 100 ,
pan_x : Number ( panXSlider . value ) / 100 ,
pan_y : Number ( panYSlider . value ) / 100 ,
upload_bg_id : state . uploadBgId ,
}),
});
if ( ! response . ok ) {
let detail = ` ${ response . status } ` ;
try {
const data = await response . json ();
if ( data . detail ) detail = data . detail ;
} catch {}
throw new Error ( detail );
}
if ( requestId !== generateRequestId ) return ;
state . cacheKey = response . headers . get ( 'X-Cache-Key' );
const blob = await response . blob ();
if ( previewImg . src . startsWith ( 'blob:' )) URL . revokeObjectURL ( previewImg . src );
previewImg . src = URL . createObjectURL ( blob );
setGenerated ( true );
if ( ! silent ) {
showToast ( `Generated ${ state . targetType . toLowerCase () } artwork` , 'ok' );
}
} catch ( error ) {
if ( error . name === 'AbortError' ) return ;
state . cacheKey = null ;
setGenerated ( false );
showToast ( `Failed: ${ error . message } ` , 'err' );
} finally {
if ( generateController === controller ) generateController = null ;
if ( requestId === generateRequestId ) {
loadingCover . classList . remove ( 'on' );
btnGenerate . disabled = ! state . item ;
}
}
}
async function applyArtwork () {
if ( ! state . item || ! state . generated ) return ;
btnApply . disabled = true ;
try {
const response = await fetch ( '/api/collections/apply' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
item_id : state . item . id ,
title : state . item . name ,
target_type : state . targetType ,
text : overlayText . value . trim () || state . item . name ,
text_color : textColorText . value ,
text_align : getSelectedTextAlign (),
text_position : getSelectedTextPosition (),
text_scale : Number ( textScaleSlider . value ) / 100 ,
darkness : Number ( darknessSlider . value ) / 100 ,
zoom : Number ( zoomSlider . value ) / 100 ,
pan_x : Number ( panXSlider . value ) / 100 ,
pan_y : Number ( panYSlider . value ) / 100 ,
upload_bg_id : state . uploadBgId ,
}),
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
showToast ( `Applied ${ data . target_type . toLowerCase () } artwork` , 'ok' );
refreshArtwork ();
} catch ( error ) {
showToast ( `Failed: ${ error . message } ` , 'err' );
} finally {
btnApply . disabled = ! state . generated ;
}
}
function finishDrag ( pointerId = null ) {
if ( ! dragState ) return ;
if ( pointerId != null && dragState . pointerId !== pointerId ) return ;
try {
previewFrame . releasePointerCapture ( dragState . pointerId );
} catch {}
previewFrame . classList . remove ( 'dragging' );
dragState = null ;
}
searchInput . addEventListener ( 'input' , () => {
clearTimeout ( searchTimer );
searchTimer = setTimeout (() => loadCollections ( searchInput . value . trim (), 0 ), 180 );
});
btnPrevPage . addEventListener ( 'click' , () => {
if ( state . searchStart === 0 ) return ;
loadCollections ( state . searchQuery , Math . max ( 0 , state . searchStart - state . searchLimit ));
});
btnNextPage . addEventListener ( 'click' , () => {
if ( state . searchStart + state . searchLimit >= state . searchTotal ) return ;
loadCollections ( state . searchQuery , state . searchStart + state . searchLimit );
});
[ thumbCard , primaryCard ]. forEach ( card => {
card . addEventListener ( 'click' , () => setTarget ( card . dataset . target ));
});
targetButtons . querySelectorAll ( '[data-target]' ). forEach ( button => {
button . addEventListener ( 'click' , () => setTarget ( button . dataset . target ));
});
alignButtons . querySelectorAll ( '[data-align]' ). forEach ( button => {
button . addEventListener ( 'click' , () => {
textAlign = button . dataset . align ;
alignButtons . querySelectorAll ( '[data-align]' ). forEach ( btn => btn . classList . remove ( 'active' ));
button . classList . add ( 'active' );
schedulePreview ();
});
});
positionButtons . querySelectorAll ( '[data-position]' ). forEach ( button => {
button . addEventListener ( 'click' , () => {
textPosition = button . dataset . position ;
positionButtons . querySelectorAll ( '[data-position]' ). forEach ( btn => btn . classList . remove ( 'active' ));
button . classList . add ( 'active' );
schedulePreview ();
});
});
overlayText . addEventListener ( 'input' , schedulePreview );
textColorPicker . addEventListener ( 'input' , () => {
textColorText . value = textColorPicker . value . toUpperCase ();
schedulePreview ();
});
textColorText . addEventListener ( 'input' , () => {
if ( /^#[0-9a-f]{6}$/i . test ( textColorText . value )) {
textColorPicker . value = textColorText . value ;
schedulePreview ();
}
});
textScaleSlider . addEventListener ( 'input' , () => setTextScale ( Number ( textScaleSlider . value ), { regenerate : true }));
darknessSlider . addEventListener ( 'input' , () => {
updatePreviewMeta ();
schedulePreview ();
});
zoomSlider . addEventListener ( 'input' , () => setCropValues ({ zoom : Number ( zoomSlider . value ) }, { regenerate : true }));
panXSlider . addEventListener ( 'input' , () => setCropValues ({ panX : Number ( panXSlider . value ) }, { regenerate : true }));
panYSlider . addEventListener ( 'input' , () => setCropValues ({ panY : Number ( panYSlider . value ) }, { regenerate : true }));
btnTextScaleDown . addEventListener ( 'click' , () => setTextScale ( Number ( textScaleSlider . value ) - 10 , { regenerate : true }));
btnTextScaleUp . addEventListener ( 'click' , () => setTextScale ( Number ( textScaleSlider . value ) + 10 , { regenerate : true }));
btnZoomOut . addEventListener ( 'click' , () => setCropValues ({ zoom : Number ( zoomSlider . value ) - 10 }, { regenerate : true }));
btnZoomIn . addEventListener ( 'click' , () => setCropValues ({ zoom : Number ( zoomSlider . value ) + 10 }, { regenerate : true }));
btnPreviewZoomOut . addEventListener ( 'click' , () => setCropValues ({ zoom : Number ( zoomSlider . value ) - 10 }, { regenerate : true }));
btnPreviewZoomIn . addEventListener ( 'click' , () => setCropValues ({ zoom : Number ( zoomSlider . value ) + 10 }, { regenerate : true }));
btnResetCrop . addEventListener ( 'click' , () => setCropValues ({ zoom : 100 , panX : 0 , panY : 0 }, { regenerate : true }));
btnGenerate . addEventListener ( 'click' , () => generatePreview ());
btnRegenerate . addEventListener ( 'click' , () => generatePreview ());
btnApply . addEventListener ( 'click' , applyArtwork );
previewFrame . addEventListener ( 'pointerdown' , event => {
if ( ! state . item || event . target . closest ( 'button' )) return ;
event . preventDefault ();
dragState = {
pointerId : event . pointerId ,
startX : event . clientX ,
startY : event . clientY ,
startPanX : Number ( panXSlider . value ),
startPanY : Number ( panYSlider . value ),
};
previewFrame . classList . add ( 'dragging' );
previewFrame . setPointerCapture ( event . pointerId );
});
previewFrame . addEventListener ( 'pointermove' , event => {
if ( ! dragState || dragState . pointerId !== event . pointerId ) return ;
const rect = previewFrame . getBoundingClientRect ();
const deltaX = event . clientX - dragState . startX ;
const deltaY = event . clientY - dragState . startY ;
setCropValues ({
panX : dragState . startPanX - ( deltaX / Math . max ( 1 , rect . width )) * 200 ,
panY : dragState . startPanY - ( deltaY / Math . max ( 1 , rect . height )) * 200 ,
}, { regenerate : true });
});
previewFrame . addEventListener ( 'pointerup' , event => finishDrag ( event . pointerId ));
previewFrame . addEventListener ( 'pointercancel' , event => finishDrag ( event . pointerId ));
previewFrame . addEventListener ( 'lostpointercapture' , () => finishDrag ());
previewFrame . addEventListener ( 'wheel' , event => {
if ( ! state . item ) return ;
event . preventDefault ();
const delta = event . deltaY < 0 ? 8 : - 8 ;
setCropValues ({ zoom : Number ( zoomSlider . value ) + delta }, { regenerate : true });
}, { passive : false });
btnChooseImage . addEventListener ( 'click' , () => uploadInput . click ());
uploadInput . addEventListener ( 'change' , () => {
const file = uploadInput . files [ 0 ];
if ( file ) uploadFile ( file );
uploadInput . value = '' ;
});
btnClearImage . addEventListener ( 'click' , () => {
state . uploadBgId = null ;
state . uploadFileName = '' ;
updateSourceUi ();
schedulePreview ();
});
[ 'dragenter' , 'dragover' ]. forEach ( eventName => {
dropzone . addEventListener ( eventName , event => {
event . preventDefault ();
dropzone . classList . add ( 'drag-over' );
});
});
[ 'dragleave' , 'dragend' , 'drop' ]. forEach ( eventName => {
dropzone . addEventListener ( eventName , event => {
event . preventDefault ();
if ( eventName !== 'drop' ) {
dropzone . classList . remove ( 'drag-over' );
}
});
});
dropzone . addEventListener ( 'drop' , event => {
dropzone . classList . remove ( 'drag-over' );
const file = event . dataTransfer ? . files ? .[ 0 ];
if ( file ) uploadFile ( file );
});
updatePreviewMeta ();
updateSelectionUi ();
updateArtworkUi ();
checkConnection ();
loadCollections ( '' , 0 );
</ script >
</ body >
</ html >