@@ -38,6 +38,14 @@ window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId ( id ) { return deviceDisplayName ( state . devices . find ( d => d . id === id ) || { id , name : id } ) ; }
function operationActionLabel ( action ) { const labels = { 'render' : [ '生成预览' , 'Generate preview' ] , 'render-ai' : [ 'AI 生成图片' , 'Generate AI image' ] , 'official-widget' : [ '更新官方订阅' , 'Update official widget' ] , 'delete' : [ '删除图片' , 'Delete image' ] , 'display' : [ '显示图片' , 'Display image' ] , 'restore' : [ '恢复图片' , 'Restore image' ] , 'local-upload' : [ '保存到图库' , 'Save to gallery' ] , 'reconcile' : [ '核对状态' , 'Reconcile' ] } ; return labels [ action ] ? . [ state . lang === 'zh' ? 0 : 1 ] || t ( 'operation' ) ; }
function operationClientLabel ( client ) { return client === 'admin' ? tr ( '管理员' , 'Administrator' ) : client === 'scheduler' ? tr ( '自动调度' , 'Scheduler' ) : client ; }
function operationResultHTML ( o ) {
const r = o . result || { } , parts = [ ] ;
if ( r . image _name ) parts . push ( ` <div> ${ esc ( r . image _name ) } </div> ` ) ;
if ( r . image _id != null ) parts . push ( ` <small> ${ t ( 'cloudImageId' ) } : ${ esc ( r . image _id ) } </small> ` ) ;
else if ( r . asset _id || r . rendered _asset _id ) parts . push ( ` <small> ${ t ( 'localAssetId' ) } : ${ esc ( r . asset _id || r . rendered _asset _id ) } </small> ` ) ;
if ( o . error ) parts . push ( ` <div class="history-status bad"> ${ esc ( operationErrorMessage ( o . error ) ) } </div> ` ) ;
return parts . join ( '' ) || '—' ;
}
function operationErrorLabel ( error ) { if ( ! error ? . code ) return '' ; const label = t ( error . code ) ; return label === error . code ? t ( 'error' ) : label ; }
function deviceDisplayName ( device ) { const name = device . name || device . id ; return device . id === 'living-room' && [ 'living-room' , 'Living room' ] . includes ( name ) ? t ( 'livingRoom' ) : name ; }
function shell ( ) { document . documentElement . lang = state . lang === 'zh' ? 'zh-CN' : 'en' ; $ ( '#lang' ) . textContent = state . lang === 'zh' ? 'EN' : '中文' ; $ ( '#theme' ) . title = t ( 'theme' ) ; $ ( '#nav' ) . innerHTML = Object . entries ( icons ) . map ( ( [ id , icon ] ) => ` <button data-page=" ${ id } " class=" ${ id === state . page ? 'active' : '' } " ${ id === state . page ? 'aria-current="page"' : '' } ><span class="nav-icon"> ${ icon } </span><span> ${ t ( id ) } </span></button> ` ) . join ( '' ) ; document . querySelectorAll ( '[data-page]' ) . forEach ( b => b . onclick = ( ) => navigate ( b . dataset . page ) ) ; $ ( '#page-title' ) . textContent = t ( state . page ) ; $ ( '#device' ) . innerHTML = ( state . devices . length ? state . devices : [ { id : state . device , name : state . device } ] ) . map ( d => ` <option value=" ${ esc ( d . id ) } " ${ d . id === state . device ? 'selected' : '' } > ${ esc ( deviceDisplayName ( d ) ) } </option> ` ) . join ( '' ) ; revealCurrentNavigation ( ) ; }
@@ -143,7 +151,7 @@ const views={
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' ) } <p> ${ t ( 'templateSteps' ) } </p></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><p class="hint"> ${ t ( 'templateGuideTitle' ) } </p><p class="hint"> ${ t ( { 'photo-text' : 'guidePhotoText' , 'daily-dashboard' : 'guideDashboard' , weather : 'guideWeather' , calendar : 'guideCalendar' , quote : 'guideQuote' , 'daily-poetry' : 'guidePoetry' , 'system-alert' : 'guideAlert' } [selected.id]||'templateContentHelp')}</p> ${ [ 'weather' , 'daily-dashboard' ] . includes ( selected . id ) ? ` <p class="hint"> ${ t ( 'templateWeatherHelp' ) } </p> ` : '' } <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><p class="hint"> ${ t ( 'helpProfile' ) } </p></div><p class="hint"> ${ t ( 'templateActionHelp' ) } </p><p class="hint"> ${ t ( 'templateAutomationHelp' ) } </p><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 ( 'templatePreviewHint' ) } </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 ( [ 'collage' , 'poster' ] . includes ( b . dataset . template ) ? 'studio' : undefined ) ; } ; 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 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 ( operationActionLabel ( o . action ) ) } <br><small> ${ esc ( deviceNameForId ( o . device _id ) ) } </small></td><td><span class="history-status ${ [ '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 || operationErrorLabel ( o . error ) || '—' ) } </td><td> ${ esc ( operationClientLabel ( 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 ( ) ; } ) ; } ) ) ; } ,
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 data-operation-id=" ${ esc ( o . operation _id ) } " ><td>${ fmt ( o . created _at ) } </td><td> ${ esc ( operationActionLabel ( o . action ) ) } <br><small> ${ esc ( deviceNameForId ( o . device _id ) ) } </small></td><td><span class="history-status ${ [ 'FAILED' , 'UNKNOWN' ] . includes ( o . status ) ? 'bad' : '' } "> ${ t ( o . status ) } </span> ${ o . status === 'UNKNOWN' ? ` <div><button data-reconcile=" ${ i } "> ${ t ( 'reconcile' ) } </button></div> ` : '' } </td><td> ${ operationResultHTML ( o ) } </td><td> ${ esc ( operationClientLabel ( 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 ( ) ; } ) ; } ) ) ; } ,
async settings ( ) { state . settings = await api ( '/api/v1/settings' ) ; const s = state . settings ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="columns settings-columns"><section class="card"><h3> ${ t ( 'settings' ) } </h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select"> ${ t ( 'theme' ) } </label><select id="theme-select"> ${ [ 'system' , 'light' , 'dark' ] . map ( x => ` <option value=" ${ x } " ${ state . theme === x ? 'selected' : '' } > ${ t ( x === 'system' ? 'systemTheme' : x ) } </option> ` ) . join ( '' ) } </select></div><div class="field"><label for="language-select"> ${ t ( 'language' ) } </label><select id="language-select"><option value="zh" ${ state . lang === 'zh' ? 'selected' : '' } >简体中文</option><option value="en" ${ state . lang === 'en' ? 'selected' : '' } >English</option></select></div></div><p class="hint"> ${ t ( 'tokenHint' ) } </p> ${ buttons ( 'logout' , 'logout' ) } </section><section class="card"><h3> ${ t ( 'auth' ) } </h3><div class="detail-grid"> ${ detail ( 'version' , s . version ) } ${ detail ( 'writeProtection' , t ( ! s . write _enabled ? 'yes' : 'no' ) ) } ${ detail ( 'keyConfigured' , t ( s . api _key _configured ? 'yes' : 'no' ) ) } ${ detail ( 'timezone' , s . timezone ) } </div><p class="hint"> ${ t ( 'refreshInterval' ) } </p><a href="/docs" target="_blank" rel="noopener"> ${ t ( 'apiDocs' ) } →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div> ` ; $ ( '#theme-select' ) . onchange = ( ) => { state . theme = $ ( '#theme-select' ) . value ; localStorage . setItem ( 'ff-theme' , state . theme ) ; applyTheme ( ) ; } ; $ ( '#language-select' ) . onchange = ( ) => { state . lang = $ ( '#language-select' ) . value ; localStorage . setItem ( 'ff-language' , state . lang ) ; navigate ( ) ; } ; bind ( 'logout' , async ( ) => { await api ( '/api/v1/auth/logout' , { method : 'POST' } ) ; forgetLogin ( ) ; shell ( ) ; login ( ) ; } ) ; accountSettings ( ) ; }
} ;
function jobPayload ( j , changes = { } ) { return { name : j . name , enabled : j . enabled , device _id : j . device _id , schedule : j . schedule , source : j . source , display _duration : j . display _duration , restore _previous : j . restore _previous , priority : j . priority , cleanup _unstarred : j . cleanup _unstarred || false , ... changes } ; }