@@ -46,9 +46,9 @@ async function templateEditor(selected){
const raw = $ ( '#template-data' ) ; const assets = await api ( '/api/v1/assets' ) ; let values = JSON . parse ( raw . value ) ;
const panel = document . createElement ( 'div' ) ; panel . id = 'template-fields' ; raw . before ( panel ) ;
const labels = { title : state . lang === 'zh' ? '标题' : 'Title' , message : state . lang === 'zh' ? '正文' : 'Message' , quote : state . lang === 'zh' ? '一句话' : 'Quote' , author : state . lang === 'zh' ? '作者' : 'Author' , events : t ( 'eventsHelp' ) , date : t ( 'date' ) , weekday : state . lang === 'zh' ? '星期(留空自动)' : 'Weekday (blank for automatic)' , weather : state . lang === 'zh' ? '天气数据(JSON,可留空使用 Provider) ' : 'Weather JSON (blank uses provider)' } ;
const fields = [ ... new Set ( [ 'title' , ... selected . fields . filter ( k => Object . hasOwn ( selected . defaults , k ) ) , ... ( [ 'daily-dashboard' , 'calendar' ] . includes ( selected . id ) ? [ 'events' , 'date' , 'weekday' ] : [ ] ) , ... ( selected . id === 'weather' || selected . id === 'daily-dashboard' ? [ 'weather' ] : [ ] ) , ... ( [ 'photo-text' , 'daily-dashboard' ] . includes ( selected . id ) ? [ 'photo_id' ] : [ ] ) ] ) ] ;
const fields = [ ... new Set ( [ 'title' , ... selected . fields . filter ( k => Object . hasOwn ( selected . defaults , k ) ) , ... ( [ 'daily-dashboard' , 'calendar' ] . includes ( selected . id ) ? [ 'events' , 'date' , 'weekday' ] : selected . id === 'daily-poetry' ? [ 'date' ] : [ ] ) , ... ( selected . id === 'weather' || selected . id === 'daily-dashboard' ? [ 'weather' ] : [ ] ) , ... ( [ 'photo-text' , 'daily-dashboard' ] . includes ( selected . id ) ? [ 'photo_id' ] : [ ] ) ] ) ] ;
function invalidate ( ) { state . templatePreview = null ; $ ( '#display-template' ) . disabled = true ; $ ( '#template-preview' ) . className = 'empty' ; $ ( '#template-preview' ) . textContent = t ( 'previewHint' ) ; }
function build ( ) { panel . innerHTML = ` <h3> ${ t ( 'formEditor' ) } </h3> ` + fields . map ( k => k === 'photo_id' ? ` <div class="field"><label for="field-photo_id"> ${ t ( 'photo' ) } </label><select id="field-photo_id" data-field="photo_id"><option value=""> ${ t ( 'noPhoto' ) } </option> ${ assets . map ( a => ` <option value=" ${ esc ( a . asset _id ) } " ${ a . asset _id === values . photo _id ? 'selected' : '' } > ${ esc ( a . name ) } </option> ` ) . join ( '' ) } </select></div> ` : ` <div class="field"><label for="field- ${ k } "> ${ esc ( labels [ k ] || k ) } </label><textarea id="field- ${ k } " data-field=" ${ k } " rows=" ${ [ 'events' , 'message' , 'weather' , 'quote' ] . includes ( k ) ? 3 : 1 } "> ${ esc ( k === 'events' ? ( values [ k ] || [ ] ) . map ( v => typeof v === 'object' ? v . title : v ) . join ( '\n' ) : [ 'weather' , 'forecast' ] . includes ( k ) ? ( values [ k ] ? JSON . stringify ( values [ k ] , null , 2 ) : '' ) : values [ k ] || '' ) } </textarea></div> ` ) . join ( '' ) ; panel . querySelectorAll ( '[data-field]' ) . forEach ( el => el . oninput = ( ) => { try { const k = el . dataset . field ; let v = el . value ; if ( k === 'events' ) v = v . split ( '\n' ) . filter ( x => x . trim ( ) ) ; if ( [ 'weather' , 'forecast' ] . includes ( k ) ) v = v . trim ( ) ? JSON . parse ( v ) : null ; if ( v === null || v === '' ) delete values [ k ] ; else values [ k ] = v ; raw . value = JSON . stringify ( values , null , 2 ) ; el . setCustomValidity ( '' ) ; invalidate ( ) ; } catch { el . setCustomValidity ( t ( 'INVALID_REQUEST' ) ) ; el . reportValidity ( ) ; } } ) ; }
function build ( ) { panel . innerHTML = ` <h3> ${ t ( 'formEditor' ) } </h3> ` + fields . map ( k => k === 'photo_id' ? ` <div class="field"><label for="field-photo_id"> ${ t ( 'photo' ) } </label><select id="field-photo_id" data-field="photo_id"><option value=""> ${ t ( 'noPhoto' ) } </option> ${ assets . map ( a => ` <option value=" ${ esc ( a . asset _id ) } " ${ a . asset _id === values . photo _id ? 'selected' : '' } > ${ esc ( a . name ) } </option> ` ) . join ( '' ) } </select></div> ` : ` <div class="field"><label for="field- ${ k } "> ${ esc ( labels [ k ] || t ( k ) ) } </label><textarea id="field- ${ k } " data-field=" ${ k } " rows=" ${ [ 'events' , 'message' , 'weather' , 'quote' ] . includes ( k ) ? 3 : 1 } "> ${ esc ( k === 'events' ? ( values [ k ] || [ ] ) . map ( v => typeof v === 'object' ? v . title : v ) . join ( '\n' ) : [ 'weather' , 'forecast' ] . includes ( k ) ? ( values [ k ] ? JSON . stringify ( values [ k ] , null , 2 ) : '' ) : values [ k ] || '' ) } </textarea></div> ` ) . join ( '' ) ; panel . querySelectorAll ( '[data-field]' ) . forEach ( el => el . oninput = ( ) => { try { const k = el . dataset . field ; let v = el . value ; if ( k === 'events' ) v = v . split ( '\n' ) . filter ( x => x . trim ( ) ) ; if ( [ 'weather' , 'forecast' ] . includes ( k ) ) v = v . trim ( ) ? JSON . parse ( v ) : null ; if ( v === null || v === '' ) delete values [ k ] ; else values [ k ] = v ; raw . value = JSON . stringify ( values , null , 2 ) ; el . setCustomValidity ( '' ) ; invalidate ( ) ; } catch { el . setCustomValidity ( t ( 'INVALID_REQUEST' ) ) ; el . reportValidity ( ) ; } } ) ; }
build ( ) ; const advanced = document . createElement ( 'details' ) ; advanced . innerHTML = ` <summary> ${ t ( 'advanced' ) } </summary> ` ; raw . before ( advanced ) ; advanced . append ( raw ) ; raw . oninput = ( ) => { invalidate ( ) ; try { values = JSON . parse ( raw . value ) ; if ( ! values || typeof values !== 'object' || Array . isArray ( values ) ) throw Error ( ) ; raw . setCustomValidity ( '' ) ; build ( ) ; } catch { raw . setCustomValidity ( t ( 'INVALID_REQUEST' ) ) ; } } ;
const reset = document . createElement ( 'button' ) ; reset . id = 'reset-template' ; reset . textContent = t ( 'resetTemplate' ) ; $ ( '#save-template' ) . after ( reset ) ; bind ( 'reset-template' , ( ) => confirmDialog ( t ( 'resetConfirm' ) , async ( ) => { await api ( '/api/v1/templates/' + selected . id + '/config' , { method : 'DELETE' } ) ; await navigate ( ) ; } ) ) ;
const save = $ ( '#save-template' ) ; const oldSave = save . onclick ; save . onclick = async e => { if ( [ ... panel . querySelectorAll ( 'textarea' ) , raw ] . some ( x => ! x . checkValidity ( ) ) ) { toast ( t ( 'INVALID_REQUEST' ) ) ; return ; } await oldSave ( e ) ; } ;
@@ -58,7 +58,7 @@ 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 ( ) ; } ) ; $ ( '#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 ( ) => { const name = $ ( '#image-name' ) . value ; $ ( '#modal' ) . close ( ) ; displayDialog ( { type : 'image' , image _name : name } ) ; } ) ; } ) ; hydrateImages ( ) ; document . querySelectorAll ( '[data-show]' ) . forEach ( b => b . onclick = busy ( ( ) => { const x = items [ b . dataset . show ] ; return displayDialog ( 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-' + clientUUID ( ) } } ) ; 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 = ! liveWriteAllowed ( ) ; } ) ; 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' , ( ) => displayDialog ( { 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 ; if ( state . pendingTemplateData ? . template === selected . id ) { selected . defaults = state . pendingTemplateData . data ; state . initialProfile = state . pendingTemplateData . profile ; state . pendingTemplateData = null ; } 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 } ; if ( [ ... document . querySelectorAll ( '#template-fields textarea' ) , $ ( '#template-data' ) ] . some ( x => ! x . checkValidity ( ) ) ) { toast ( t ( 'INVALID_REQUEST' ) ) ; return ; } 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 = ! liveWriteAllowed ( ) ; } ) ; 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' , ( ) => displayDialog ( { type : 'image' , asset _id : state . templatePreview } ) ) ; await templateEditor ( selected ) ; if ( state . initialProfile ) { $ ( '#template-profile' ) . value = state . initialProfile ; state . initialProfile = null ; } } ,
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 ; if ( state . pendingTemplateData ? . template === selected . id ) { selected . defaults = state . pendingTemplateData . data ; state . initialProfile = state . pendingTemplateData . profile ; state . pendingTemplateData = null ; } 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> ${ { 'daily-poetry' : '詩' , 'calendar' : '▦' , 'daily-dashboard' : '☀' , 'photo-text' : '▣' , 'quote' : '❝' , 'system-alert' : '◒' , 'weather' : '☁' } [x.id]||'▦'} </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 } ; if ( [ ... document . querySelectorAll ( '#template-fields textarea' ) , $ ( '#template-data' ) ] . some ( x => ! x . checkValidity ( ) ) ) { toast ( t ( 'INVALID_REQUEST' ) ) ; return ; } 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 = ! liveWriteAllowed ( ) ; } ) ; 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' , ( ) => displayDialog ( { type : 'image' , asset _id : state . templatePreview } ) ) ; await templateEditor ( selected ) ; if ( state . initialProfile ) { $ ( '#template-profile' ) . value = state . initialProfile ; state . initialProfile = null ; } } ,
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 ( ) ; } ) ) ; } ,
async history ( epoch ) { const ops = await api ( '/api/v1/operations' ) ; if ( epoch !== state . epoch ) return ; $ ( '#main' ) . innerHTML = ` <div class="section-heading"><h2> ${ t ( 'history' ) } </h2> ${ buttons ( 'history-refresh' , 'refresh' ) } </div> ${ ops . some ( x => x . status === 'UNKNOWN' ) ? ` <div class="notice warning"> ${ t ( 'unknown' ) } </div> ` : '' } <div class="card table-wrap"> ${ ops . length ? ` <table><thead><tr> ${ [ 'created' , 'action' , 'status' , 'result' , 'client' , 'requestId' ] . map ( x => ` <th> ${ t ( x ) } </th> ` ) . join ( '' ) } </tr></thead><tbody> ${ ops . map ( ( o , i ) => ` <tr><td> ${ fmt ( o . created _at ) } </td><td> ${ esc ( o . action ) } <br><small> ${ esc ( o . device _id ) } </small></td><td><span class="badge ${ [ 'FAILED' , 'UNKNOWN' ] . includes ( o . status ) ? 'bad' : '' } "> ${ t ( o . status ) } </span> ${ o . status === 'UNKNOWN' ? ` <div><button data-reconcile=" ${ i } "> ${ t ( 'reconcile' ) } </button></div> ` : '' } </td><td> ${ esc ( o . result ? . image _id || o . result ? . asset _id || o . error ? . code || '—' ) } </td><td> ${ esc ( o . client _id ) } </td><td><code> ${ esc ( o . request _id ) } <br> ${ esc ( o . operation _id ) } </code></td></tr> ` ) . join ( '' ) } </tbody></table> ` : ` <div class="empty"> ${ t ( 'noHistory' ) } </div> ` } </div> ` ; bind ( 'history-refresh' , ( ) => navigate ( ) ) ; document . querySelectorAll ( '[data-reconcile]' ) . forEach ( b => b . onclick = busy ( async ( ) => { const o = ops [ b . dataset . reconcile ] ; const r = await api ( '/api/v1/operations/' + o . operation _id + '/reconcile' , { method : 'POST' , body : { acknowledge : false } } ) ; showModal ( ` <h2> ${ t ( 'reconcile' ) } </h2><p> ${ t ( 'current' ) } : ${ esc ( r . current _image _id ) } </p><p> ${ t ( 'result' ) } : ${ esc ( r . expected _image _id ) } </p><p class="hint"> ${ t ( 'unknown' ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } ${ r . resolved ? '' : buttons ( 'acknowledge' , 'acknowledge' ) } </div> ` ) ; bind ( 'acknowledge' , async ( ) => { await api ( '/api/v1/operations/' + o . operation _id + '/reconcile' , { method : 'POST' , body : { acknowledge : true } } ) ; $ ( '#modal' ) . close ( ) ; await navigate ( ) ; } ) ; } ) ) ; } ,