@@ -37,9 +37,9 @@ function showModal(html){$('#modal-content').innerHTML=html;$('#modal').showModa
async function confirmDialog ( message , fn ) { showModal ( ` <h2> ${ t ( 'confirm' ) } </h2><p> ${ esc ( message ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } ${ buttons ( 'modal-confirm' , 'confirm' , true ) } </div> ` ) ; bind ( 'modal-confirm' , async ( ) => { await fn ( ) ; $ ( '#modal' ) . close ( ) ; } ) ; }
function detail ( label , value ) { return ` <div><small> ${ t ( label ) } </small><strong> ${ esc ( value ) } </strong></div> ` ; }
const views = {
async dashboard ( epoch ) { const d = await api ( '/api/v1/devices/' + state . device ) ; if ( epoch !== state . epoch ) return ; const s = d . slideshow ; $ ( '#main' ) . innerHTML = notice ( ) + ` ${ d . power . battery _percent !== null && d . power . battery _percent < 20 ? ` <div class="notice warning"> ${ t ( 'lowBattery' ) } </div> ` : '' } <section class="card hero"><div class="hero-info"><span class="badge ${ d . status === 'offline' ? 'bad' : '' } ">● ${ t ( d . status ) . toUpperCase ( ) } </span><h2> ${ esc ( d . name ) } </h2><p> ${ esc ( d . model ) } · ${ esc ( d . model _code ) } · ${ d . display . width } × ${ d . display . height } </p><div class="actions"> ${ buttons ( 'go-upload' , 'upload' , true ) } ${ buttons ( 'restore' , 'restore' ) } ${ buttons ( 'refresh-device' , 'refresh' ) } </div><p class="hint" style="margin-top:28px"> ${ t ( 'refreshInterval' ) } </p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label"> ${ t ( 'testLabel' ) } </span></div></section><div class="stats"> ${ [ [ 'battery' , d . power . battery _percent === null ? '—' : d . power . battery _percent + '%' ] , [ 'current' , d . current _content . id ? ? '—' ] , [ 'sync' , t ( d . current _content . synced ? 'synced' : 'syncing' ) ] , [ 'firmware' , d . firmware ] ] . map ( ( [ l , v ] ) => ` <div class="card stat"><div class="label"> ${ t ( l ) } </div><div class="value"> ${ esc ( v ) } </div></div> ` ) . join ( '' ) } </div><div class="columns"><section class="card"><h3> ${ t ( 'device' ) } </h3><div class="detail-grid"> ${ detail ( 'heartbeat' , fmt ( d . last _seen _at ) ) } ${ detail ( 'rotation' , d . display . rotation + '°' ) } ${ detail ( 'timezone' , d . timezone ) } ${ detail ( 'coverId' , d . current _content . cover _id ? ? '—' ) } ${ detail ( 'storage' , d . storage _used _bytes === null ? '—' : ( d . storage _used _bytes / 1024 / 1024 ) . toFixed ( 1 ) + ' MiB' ) } ${ detail ( 'model' , t ( d . model _verified ? 'modelVerified' : 'modelConfigured' ) ) } </div></section><section class="card"><h3> ${ t ( 'cloudRotation' ) } </h3><div class="detail-grid"> ${ detail ( 'mode' , s . mode ? t ( s . mode ) : '—' ) } ${ detail ( 'interval' , s . interval _minutes == null ? '—' : s . interval _minutes + ' ' + t ( 'minutes' ) ) } </div><p class="hint"> ${ t ( 'rotationNotice' ) } </p><a href="#playlists" id="go-playlists"> ${ t ( 'playlists' ) } →</a></section></div> ` ; bind ( 'go-upload' , ( ) => navigate ( 'upload' ) ) ; bind ( 'refresh-device' , async ( ) => { await api ( '/api/v1/devices/' + state . device + '?refresh=true' ) ; await navigate ( ) ; } ) ; bind ( 'restore' , ( ) => accepted ( '/api/v1/devices/' + state . device + '/restore' , { } ) ) ; $ ( '#go-playlists' ) . onclick = e => { e . preventDefault ( ) ; navigate ( 'playlists' ) ; } ; } ,
async dashboard ( epoch ) { const d = await api ( '/api/v1/devices/' + state . device ) ; if ( epoch !== state . epoch ) return ; const s = d . slideshow ; $ ( '#main' ) . innerHTML = notice ( ) + ` ${ d . power . battery _percent !== null && d . power . battery _percent < 20 ? ` <div class="notice warning"> ${ t ( 'lowBattery' ) } </div> ` : '' } <section class="card hero"><div class="hero-info"><span class="badge ${ d . status === 'offline' ? 'bad' : '' } ">● ${ t ( d . status ) . toUpperCase ( ) } </span><h2> ${ esc ( d . name ) } </h2><p> ${ esc ( d . model ) } · ${ esc ( d . model _code ) } · ${ d . display . width } × ${ d . display . height } </p><div class="actions"> ${ buttons ( 'go-upload' , 'upload' , true ) } ${ buttons ( 'restore' , 'restore' ) } ${ buttons ( 'refresh-device' , 'refresh' ) } </div><p class="hint" style="margin-top:28px"> ${ t ( 'refreshInterval' ) } </p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label"> ${ t ( 'testLabel' ) } </span></div></section><div class="stats"> ${ [ [ 'battery' , d . power . battery _percent === null ? '—' : d . power . battery _percent + '%' ] , [ 'current' , d . current _content . id ? ? '—' ] , [ 'sync' , t ( d . current _content . synced ? 'synced' : 'syncing' ) ] , [ 'firmware' , d . firmware ] ] . map ( ( [ l , v ] ) => ` <div class="card stat"><div class="label"> ${ t ( l ) } </div><div class="value"> ${ esc ( v ) } </div></div> ` ) . join ( '' ) } </div><div class="columns"><section class="card"><h3> ${ t ( 'device' ) } </h3><div class="detail-grid"> ${ detail ( 'heartbeat' , fmt ( d . last _seen _at ) ) } ${ detail ( 'rotation' , d . display . rotation + '°' ) } ${ detail ( 'timezone' , d . timezone ) } ${ detail ( 'coverId' , d . current _content . cover _id ? ? '—' ) } ${ detail ( 'storage' , d . storage _used _bytes === null ? '—' : ( d . storage _used _bytes / 1024 / 1024 ) . toFixed ( 1 ) + ' MiB' ) } ${ detail ( 'model' , t ( d . model _verified ? 'modelVerified' : 'modelConfigured' ) ) } </div></section><section class="card"><h3> ${ t ( 'cloudRotation' ) } </h3><div class="detail-grid"> ${ detail ( 'mode' , s . mode ? t ( s . mode ) : '—' ) } ${ detail ( 'interval' , s . interval _minutes == null ? '—' : s . interval _minutes + ' ' + t ( 'minutes' ) ) } </div><p class="hint"> ${ t ( 'rotationNotice' ) } </p><a href="#playlists" id="go-playlists"> ${ t ( 'playlists' ) } →</a></section></div> ` ; bind ( 'go-upload' , ( ) => navigate ( 'upload' ) ) ; bind ( 'refresh-device' , async ( ) => { await api ( '/api/v1/devices/' + state . device + '?refresh=true' ) ; await navigate ( ) ; } ) ; $ ( '#restore' ) . disabled = ! d . can _restore ; bind ( 'restore' , ( ) => accepted ( '/api/v1/devices/' + state . device + '/restore' , { } ) ) ; $ ( '#go-playlists' ) . onclick = e => { e . preventDefault ( ) ; navigate ( 'playlists' ) ; } ; } ,
async gallery ( epoch ) { let items = state . tab === 'cloud' ? await api ( '/api/v1/devices/' + state . device + '/images' ) : await api ( '/api/v1/assets' ) ; if ( epoch !== state . epoch ) return ; state . gallery = items ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="section-heading"><div class="tabs"><button id="cloud-tab" class=" ${ state . tab === 'cloud' ? 'active' : '' } "> ${ t ( 'cloud' ) } </button><button id="local-tab" class=" ${ state . tab === 'local' ? 'active' : '' } "> ${ t ( 'local' ) } </button></div><div class="actions"> ${ buttons ( 'gallery-refresh' , 'refresh' ) } ${ state . tab === 'cloud' ? buttons ( 'show-name' , 'displayByName' ) : '' } ${ buttons ( 'gallery-upload' , 'upload' , true ) } </div></div> ${ state . tab === 'local' ? ` <div class="notice"> ${ t ( 'uploadNotice' ) } </div> ` : '' } ${ items . length ? ` <div class="gallery"> ${ items . map ( ( x , i ) => ` <article class="card image-card"><img class="thumb" alt=" ${ esc ( x . name ) } " data-src=" ${ esc ( x . url + ( state . tab === 'cloud' ? '?thumbnail=true' : '' ) ) } "><h3 title=" ${ esc ( x . name ) } "> ${ esc ( x . name ) } </h3><small> ${ state . tab === 'cloud' ? '#' + x . image _id : 'LOCAL' } · ${ esc ( fmt ( x . created _at ) ) } </small> ${ x . current ? ` <div><span class="badge"> ${ t ( 'current' ) } </span></div> ` : '' } <div class="actions"><button class="primary" data-show=" ${ i } "> ${ t ( 'display' ) } </button><button data-original=" ${ i } "> ${ t ( 'original' ) } </button><button class="danger" data-delete=" ${ i } "> ${ t ( 'delete' ) } </button></div></article> ` ) . join ( '' ) } </div> ` : ` <div class="card empty"><strong> ${ t ( 'noImages' ) } </strong> ${ t ( 'emptyHint' ) } </div> ` } ` ; bind ( 'cloud-tab' , ( ) => { state . tab = 'cloud' ; return navigate ( ) ; } ) ; bind ( 'local-tab' , ( ) => { state . tab = 'local' ; return navigate ( ) ; } ) ; bind ( 'gallery-refresh' , ( ) => navigate ( ) ) ; bind ( 'gallery-upload' , ( ) => navigate ( 'upload' ) ) ; bind ( 'show-name' , ( ) => { showModal ( ` <h2> ${ t ( 'displayByName' ) } </h2><div class="field"><label for="image-name"> ${ t ( 'name' ) } </label><input id="image-name" maxlength="120"></div><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } ${ buttons ( 'show-name-confirm' , 'display' , true ) } </div> ` ) ; bind ( 'show-name-confirm' , async ( ) => { await accepted ( '/api/v1/display' , { device _id : state . device , content : { type : 'image' , image _name : $ ( '#image-name' ) . value } } ) ; $ ( '#modal' ) . close ( ) ; } ) ; } ) ; hydrateImages ( ) ; document . querySelectorAll ( '[data-show]' ) . forEach ( b => b . onclick = busy ( ( ) => { const x = items [ b . dataset . show ] ; return accepted ( '/api/v1/display' , { device _id : state . device , content : state . tab === 'cloud' ? { type : 'image' , image _id : x . image _id } : { type : 'image' , asset _id : x . asset _id } } ) ; } ) ) ; document . querySelectorAll ( '[data-original]' ) . forEach ( b => b . onclick = busy ( async ( ) => { const x = items [ b . dataset . original ] ; showModal ( ` <h2> ${ esc ( x . name ) } </h2><img id="original-image" class="preview" alt=" ${ esc ( x . name ) } "><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } </div> ` ) ; await blobImage ( $ ( '#original-image' ) , x . url ) ; } ) ) ; document . querySelectorAll ( '[data-delete]' ) . forEach ( b => b . onclick = ( ) => { const x = items [ b . dataset . delete ] ; confirmDialog ( x . current ? t ( 'deleteCurrent' ) : t ( 'deleteConfirm' ) , async ( ) => { if ( state . tab === 'local' ) { await api ( '/api/v1/assets/' + x . asset _id , { method : 'DELETE' } ) ; } else { const result = await api ( ` /api/v1/devices/ ${ state . device } /images/ ${ x . image _id } ?confirm_current= ${ x . current } ` , { method : 'DELETE' , headers : { 'Idempotency-Key' : 'web-' + crypto . randomUUID ( ) } } ) ; await waitOperation ( result . operation _id ) ; } await navigate ( ) ; } ) ; } ) ; } ,
async upload ( ) { state . preview = null ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="notice"> ${ t ( 'uploadNotice' ) } </div><div class="columns"><section class="card"><h2> ${ t ( 'upload' ) } </h2><div class="dropzone"><strong> ${ t ( 'selectFile' ) } </strong><p class="hint"> ${ t ( 'uploadHint' ) } </p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label=" ${ t ( 'selectFile' ) } "></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit"> ${ t ( 'mode' ) } </label><select id="fit"> ${ [ 'contain' , 'cover' , 'smart' ] . map ( x => ` <option value=" ${ x } "> ${ t ( x ) } </option> ` ) . join ( '' ) } </select></div><div class="field"><label for="profile"> ${ t ( 'profile' ) } </label><select id="profile"> ${ [ 'color' , 'grayscale' ] . map ( x => ` <option value=" ${ x } "> ${ t ( x ) } </option> ` ) . join ( '' ) } </select></div></div> ${ buttons ( 'make-preview' , 'preview' , true ) } <p class="hint"> ${ t ( 'previewHint' ) } </p></section><section class="card"><h3> ${ t ( 'preview' ) } </h3><div id="preview-box" class="empty"> ${ t ( 'previewHint' ) } </div><div class="actions" style="margin-top:18px"><button id="save-local" disabled> ${ t ( 'staging' ) } </button><button id="display-upload" class="primary" disabled> ${ t ( 'display' ) } </button></div></section></div> ` ; bind ( 'make-preview' , async ( ) => { const file = $ ( '#file' ) . files [ 0 ] ; if ( ! file ) throw new Error ( t ( 'selectFile' ) ) ; const form = new FormData ( ) ; form . append ( 'file' , file ) ; form . append ( 'fit' , $ ( '#fit' ) . value ) ; form . append ( 'profile' , $ ( '#profile' ) . value ) ; state . preview = await api ( '/api/v1/preview' , { method : 'POST' , body : form } ) ; $ ( '#preview-box' ) . className = '' ; $ ( '#preview-box' ) . innerHTML = '<img id="upload-preview" class="preview" alt="Preview">' ; await blobImage ( $ ( '#upload-preview' ) , state . preview . url ) ; $ ( '#save-local' ) . disabled = false ; $ ( '#display-upload' ) . disabled = false ; } ) ; for ( const id of [ 'file' , 'fit' , 'profile' ] ) $ ( '#' + id ) . onchange = ( ) => { state . preview = null ; $ ( '#save-local' ) . disabled = true ; $ ( '#display-upload' ) . disabled = true ; } ; bind ( 'save-local' , ( ) => { toast ( t ( 'done' ) ) ; state . tab = 'local' ; return navigate ( 'gallery' ) ; } ) ; bind ( 'display-upload' , ( ) => accepted ( '/api/v1/display' , { device _id : state . device , content : { type : 'image' , asset _id : state . preview . asset _id } } ) ) ; } ,
async upload ( ) { state . preview = null ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="notice"> ${ t ( 'uploadNotice' ) } </div><div class="columns"><section class="card"><h2> ${ t ( 'upload' ) } </h2><div class="dropzone"><strong> ${ t ( 'selectFile' ) } </strong><p class="hint"> ${ t ( 'uploadHint' ) } </p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label=" ${ t ( 'selectFile' ) } "></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit"> ${ t ( 'mode' ) } </label><select id="fit"> ${ [ 'contain' , 'cover' , 'smart' ] . map ( x => ` <option value=" ${ x } "> ${ t ( x ) } </option> ` ) . join ( '' ) } </select></div><div class="field"><label for="profile"> ${ t ( 'profile' ) } </label><select id="profile"> ${ [ 'color' , 'grayscale' ] . map ( x => ` <option value=" ${ x } "> ${ t ( x ) } </option> ` ) . join ( '' ) } </select></div></div> ${ buttons ( 'make-preview' , 'preview' , true ) } <p class="hint"> ${ t ( 'previewHint' ) } </p></section><section class="card"><h3> ${ t ( 'preview' ) } </h3><div id="preview-box" class="empty"> ${ t ( 'previewHint' ) } </div><div class="actions" style="margin-top:18px"><button id="save-local" disabled> ${ t ( 'staging' ) } </button><button id="display-upload" class="primary" disabled> ${ t ( 'display' ) } </button></div></section></div> ` ; bind ( 'make-preview' , async ( ) => { const file = $ ( '#file' ) . files [ 0 ] ; if ( ! file ) throw new Error ( t ( 'selectFile' ) ) ; const form = new FormData ( ) ; form . append ( 'file' , file ) ; form . append ( 'fit' , $ ( '#fit' ) . value ) ; form . append ( 'profile' , $ ( '#profile' ) . value ) ; state . preview = await api ( '/api/v1/preview' , { method : 'POST' , body : form } ) ; $ ( '#preview-box' ) . className = '' ; $ ( '#preview-box' ) . innerHTML = '<img id="upload-preview" class="preview" alt="Preview">' ; await blobImage ( $ ( '#upload-preview' ) , state . preview . url ) ; $ ( '#save-local' ) . disabled = false ; $ ( '#display-upload' ) . disabled = false ; } ) ; for ( const id of [ 'file' , 'fit' , 'profile' ] ) $ ( '#' + id ) . onchange = ( ) => { state . preview = null ; $ ( '#save-local' ) . disabled = true ; $ ( '#display-upload' ) . disabled = true ; } ; bind ( 'save-local' , async ( ) => { const id = await accepted ( '/api/v1/devices/' + state . device + '/upload' , { type : 'image' , asset _id : state . preview . asset _id } ) ; await waitOperation ( id ) ; state . tab = 'local' ; return navigate ( 'gallery' ) ; } ) ; bind ( 'display-upload' , ( ) => accepted ( '/api/v1/display' , { device _id : state . device , content : { type : 'image' , asset _id : state . preview . asset _id } } ) ) ; } ,
async templates ( ) { state . templates = await api ( '/api/v1/templates' ) ; const selected = state . templates . find ( x => x . id === state . selectedTemplate ) || state . templates [ 0 ] ; state . selectedTemplate = selected . id ; state . templatePreview = null ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="notice"> ${ t ( 'templateHint' ) } </div><div class="template-grid"> ${ state . templates . map ( ( x , i ) => ` <div class="card template-card ${ x . id === selected . id ? 'selected' : '' } " role="button" tabindex="0" data-template=" ${ x . id } "><span> ${ [ '☀' , '▦' , '◒' , '▣' , '❝' , '☁' ] [ i ] } </span><div><h3> ${ esc ( x . name . split ( ' / ' ) [ state . lang === 'zh' ? 1 : 0 ] || x . name ) } </h3><small>1200 × 1600 · ${ esc ( x . id ) } </small></div></div> ` ) . join ( '' ) } </div><div class="columns"><section class="card"><h3> ${ t ( 'templateData' ) } </h3><textarea id="template-data" aria-label=" ${ t ( 'templateData' ) } "> ${ esc ( JSON . stringify ( selected . defaults , null , 2 ) ) } </textarea><div class="field" style="margin-top:16px"><label for="template-profile"> ${ t ( 'profile' ) } </label><select id="template-profile"><option value="color"> ${ t ( 'color' ) } </option><option value="grayscale"> ${ t ( 'grayscale' ) } </option></select></div><div class="actions"> ${ buttons ( 'render-template' , 'render' , true ) } ${ buttons ( 'save-template' , 'saveTemplate' ) } </div></section><section class="card"><h3> ${ t ( 'preview' ) } </h3><div id="template-preview" class="empty"> ${ t ( 'previewHint' ) } </div><button id="display-template" class="primary" style="margin-top:18px" disabled> ${ t ( 'display' ) } </button></section></div> ` ; document . querySelectorAll ( '[data-template]' ) . forEach ( b => { b . onclick = ( ) => { state . selectedTemplate = b . dataset . template ; navigate ( ) ; } ; b . onkeydown = e => { if ( e . key === 'Enter' ) b . click ( ) ; } ; } ) ; bind ( 'render-template' , async ( ) => { const content = { type : 'template' , template : selected . id , data : JSON . parse ( $ ( '#template-data' ) . value ) , profile : $ ( '#template-profile' ) . value } ; const id = await accepted ( '/api/v1/render' , content ) ; $ ( '#template-preview' ) . textContent = t ( 'previewLoading' ) ; const op = await waitOperation ( id ) ; state . templatePreview = op . result . asset _id ; $ ( '#template-preview' ) . className = '' ; $ ( '#template-preview' ) . innerHTML = '<img id="template-image" class="preview" alt="Template preview">' ; await blobImage ( $ ( '#template-image' ) , op . result . url ) ; $ ( '#display-template' ) . disabled = false ; } ) ; bind ( 'save-template' , async ( ) => { await api ( '/api/v1/templates/' + selected . id + '/config' , { method : 'PUT' , body : { data : JSON . parse ( $ ( '#template-data' ) . value ) } } ) ; toast ( t ( 'done' ) ) ; } ) ; $ ( '#template-data' ) . oninput = ( ) => $ ( '#display-template' ) . disabled = true ; $ ( '#template-profile' ) . onchange = ( ) => $ ( '#display-template' ) . disabled = true ; bind ( 'display-template' , ( ) => accepted ( '/api/v1/display' , { device _id : state . device , content : { type : 'image' , asset _id : state . templatePreview } } ) ) ; } ,
async automation ( epoch ) { const jobs = await api ( '/api/v1/jobs' ) ; if ( epoch !== state . epoch ) return ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="section-heading"><p class="hint">Asia/Shanghai · ${ t ( 'automation' ) } </p> ${ buttons ( 'new-job' , 'newJob' , true ) } </div><div class="card"> ${ jobs . length ? ` <div class="table-wrap"><table><thead><tr> ${ [ 'name' , 'schedule' , 'status' , 'nextRun' , 'lastRun' , 'result' , 'action' ] . map ( x => ` <th> ${ t ( x ) } </th> ` ) . join ( '' ) } </tr></thead><tbody> ${ jobs . map ( ( j , i ) => ` <tr><td><strong> ${ esc ( j . name ) } </strong><br><small> ${ esc ( j . device _id ) } </small></td><td> ${ t ( j . schedule . type ) } <br> ${ esc ( j . schedule . time || j . schedule . cron || fmt ( j . schedule . at ) ) } </td><td><span class="badge"> ${ t ( j . enabled ? 'enabled' : 'disabled' ) } </span></td><td> ${ fmt ( j . next _run ) } </td><td> ${ fmt ( j . last _run ) } </td><td><code> ${ esc ( j . last _result ) } </code></td><td><div class="row-actions"><button data-job-edit=" ${ i } "> ${ t ( 'edit' ) } </button><button data-job-toggle=" ${ i } "> ${ t ( j . enabled ? 'disabled' : 'enabled' ) } </button><button class="danger" data-job-delete=" ${ i } "> ${ t ( 'remove' ) } </button></div></td></tr> ` ) . join ( '' ) } </tbody></table></div> ` : ` <div class="empty"><strong> ${ t ( 'noTasks' ) } </strong></div> ` } </div> ` ; bind ( 'new-job' , ( ) => jobEditor ( ) ) ; document . querySelectorAll ( '[data-job-edit]' ) . forEach ( b => b . onclick = ( ) => jobEditor ( jobs [ b . dataset . jobEdit ] ) ) ; document . querySelectorAll ( '[data-job-toggle]' ) . forEach ( b => b . onclick = busy ( async ( ) => { const j = jobs [ b . dataset . jobToggle ] ; await api ( '/api/v1/jobs/' + j . id , { method : 'PUT' , body : jobPayload ( j , { enabled : ! j . enabled } ) } ) ; await navigate ( ) ; } ) ) ; document . querySelectorAll ( '[data-job-delete]' ) . forEach ( b => b . onclick = ( ) => confirmDialog ( t ( 'removeConfirm' ) , async ( ) => { await api ( '/api/v1/jobs/' + jobs [ b . dataset . jobDelete ] . id , { method : 'DELETE' } ) ; await navigate ( ) ; } ) ) ; } ,
async playlists ( epoch ) { const playlists = await api ( '/api/v1/playlists' ) ; if ( epoch !== state . epoch ) return ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="notice"> ${ t ( 'playlistNotice' ) } </div><div class="section-heading"><h2> ${ t ( 'playlists' ) } </h2> ${ buttons ( 'new-playlist' , 'newPlaylist' , true ) } </div><div class="gallery"> ${ playlists . map ( ( p , i ) => ` <section class="card"><span class="badge"> ${ t ( p . enabled ? 'enabled' : 'disabled' ) } </span><h3 style="margin-top:16px"> ${ esc ( p . name ) } </h3><p class="hint"> ${ p . image _ids . length } ${ t ( 'count' ) } · ${ p . interval _minutes } ${ t ( 'minutes' ) } · ${ t ( p . mode ) } </p><p><code> ${ esc ( p . image _ids . join ( ' → ' ) ) } </code></p><small> ${ t ( 'lastScheduled' ) } : ${ p . position >= 0 ? esc ( p . image _ids [ p . position ] ) : '—' } </small><div class="actions" style="margin-top:16px"><button data-pl-edit=" ${ i } "> ${ t ( 'edit' ) } </button><button class="danger" data-pl-delete=" ${ i } "> ${ t ( 'remove' ) } </button></div></section> ` ) . join ( '' ) || ` <div class="card empty"><strong> ${ t ( 'noTasks' ) } </strong></div> ` } </div> ` ; bind ( 'new-playlist' , ( ) => playlistEditor ( ) ) ; document . querySelectorAll ( '[data-pl-edit]' ) . forEach ( b => b . onclick = ( ) => playlistEditor ( playlists [ b . dataset . plEdit ] ) ) ; document . querySelectorAll ( '[data-pl-delete]' ) . forEach ( b => b . onclick = ( ) => confirmDialog ( t ( 'removeConfirm' ) , async ( ) => { await api ( '/api/v1/playlists/' + playlists [ b . dataset . plDelete ] . id , { method : 'DELETE' } ) ; await navigate ( ) ; } ) ) ; } ,