@@ -1,8 +1,13 @@
'use strict' ;
const $ = s => document . querySelector ( s ) ;
const state = { lang : localStorage . getItem ( 'ff-language' ) || 'zh' , theme : localStorage . getItem ( 'ff-theme' ) || 'system' , page : location . hash . slice ( 1 ) || 'dashboard' , token : '' , csrf : '' , device : 'living-room' , settings : null , devices : [ ] , templates : [ ] , gallery : [ ] , assets : [ ] , blobs : [ ] , tab : 'cloud' , preview : null , templatePreview : null , selectedTemplate : null , epoch : 0 } ;
function forgetLogin ( ) { state . galleryCache ? . clear ( ) ; localStorage . removeItem ( 'ff-login' ) ; state . token = '' ; state . csrf = '' ; state . settings = null ; state . devices = [ ] ; state . templates = [ ] ; }
async function loadConnection ( ) { const [ settings , devices , templates ] = await Promise . all ( [ api ( '/api/v1/settings' ) , api ( '/api/v1/devices' ) . catch ( error => { toast ( error . message ) ; return [ ] ; } ) , api ( '/api/v1/templates' ) ] ) ; state . settings = settings ; state . devices = devices ; state . templates = templates ; state . device = state . devices [ 0 ] ? . id || state . device ; }
function forgetLogin ( ) { state . galleryQuery = '' ; state . galleryOrder = 'newest' ; state . galleryJobsRequest = null ; state . galleryCache ? . clear ( ) ; localStorage . removeItem ( 'ff-login' ) ; state . token = '' ; state . csrf = '' ; state . settings = null ; state . devices = [ ] ; state . templates = [ ] ; }
async function loadConnection ( ) {
const [ settings , templates ] = await Promise . all ( [ api ( '/api/v1/settings' ) , api ( '/api/v1/templates' ) ] ) ;
const devices = settings . device _catalog || await api ( '/api/v1/devices' ) . catch ( error => { toast ( error . message ) ; return [ ] ; } ) ;
state . settings = settings ; state . devices = devices ; state . templates = templates ; state . device = devices [ 0 ] ? . id || settings . device _id || state . device ;
if ( settings . device _catalog && state . page !== 'dashboard' ) void api ( '/api/v1/devices' ) . then ( details => { if ( state . settings !== settings || ! state . token ) return ; state . devices = details ; shell ( ) ; } ) . catch ( error => { if ( state . settings === settings && state . token ) toast ( error . message ) ; } ) ;
}
const icons = { dashboard : '◫' , gallery : '▧' , 'photo-sources' : '▣' , upload : '↥' , templates : '▦' , studio : '▦' , automation : '◷' , playlists : '▤' , history : '↺' , connections : '↔' , settings : '⚙' } ;
const t = k => window . MESSAGES [ state . lang ] [ k ] || k ;
const esc = v => String ( v ? ? '—' ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
@@ -111,16 +116,22 @@ const views={
studio : ( ) => layoutStudioPage ( ) ,
connections : ( ) => connectionsPage ( ) ,
'photo-sources' : async ( ) => { $ ( '#main' ) . innerHTML = '' ; await photoSourcesPanel ( ) ; } ,
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></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 dashboard ( epoch ) { const d = await api ( '/api/v1/devices/' + state . device ) ; if ( epoch !== state . epoch ) return ; state . devices = state . devices . map ( x => x . id === d . id ? { ... x , name : d . name } : x ) ; shell ( ) ; 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></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 ) {
const destinations = galleryDestinations ( ) ; if ( ! destinations . some ( d => d . id === state . tab ) ) state . tab = destinations [ 0 ] ? . id || 'local' ;
const key = JSON . stringify ( [ state . device , state . tab , state . assetOffset || 0 , state . assetOrigin || 'all' , state . assetJob || '' ] ) ;
const cache = state . galleryCache || ( state . galleryCache = new Map ( ) ) ; const cached = cache . get ( key ) ;
const render = items => { if ( epoch !== state . epoch ) return ; state . gallery = items ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="section-heading"><div class="tabs gallery-tabs"> ${ destinations . map ( d => ` <button id=" ${ esc ( d . id ) } -tab" data-gallery-destination=" ${ esc ( d . id ) } " class=" ${ state . tab === d . id ? 'active' : '' } "> ${ esc ( galleryDestinationName ( d ) ) } </button> ` ) . join ( '' ) } </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><div class="actions"><button class="primary" data-show=" ${ i } " ${ x . current ? 'disabled' : '' } > ${ t ( x . current ? 'current' : '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 } ) ; } ) ; } ) ; enhanceGallery ( ) ; guardDeviceButtons ( ) ; hydrateImages ( ) ; document . querySelectorAll ( '[data-show]' ) . forEach ( b => b . onclick = busy ( ( ) => { const x = items [ b . dataset . show ] ; if ( x . current ) return ; 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 ( ) ; } ) ; } ) ; } ;
if ( cached ) render ( cached ) ;
const render = ( items , pending = false ) => { if ( epoch !== state . epoch ) return ; state . gallery = items ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="section-heading"><div class="tabs gallery-tabs"> ${ destinations . map ( d => ` <button id=" ${ esc ( d . id ) } -tab" data-gallery-destination=" ${ esc ( d . id ) } " class=" ${ state . tab === d . id ? 'active' : '' } "> ${ esc ( galleryDestinationName ( d ) ) } </button> ` ) . join ( '' ) } </div><div class="actions"> ${ buttons ( 'gallery-refresh' , 'refresh' ) } ${ state . tab === 'cloud' ? buttons ( 'show-name' , 'displayByName' ) : '' } ${ buttons ( 'gallery-upload' , 'upload' , true ) } <span id="gallery-list-status" class="hint" role="status" ${ pending ? '' : 'hidden' } > ${ pending ? t ( 'galleryListLoading' ) : '' } </span>< /div></div>${ state . tab === 'local' ? ` <div class="notice"> ${ t ( 'uploadNotice' ) } </div> ` : '' } ${ ` <div class="gallery" aria-busy=" ${ pending } " >${ 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><div class="actions"><button class="primary" data-show=" ${ i } " ${ x . current ? 'disabled' : '' } > ${ t ( x . current ? 'current' : 'display' ) } </button><button data-original=" ${ i } "> ${ t ( 'original' ) } </button><button class="danger" data-delete=" ${ i } "> ${ t ( 'delete' ) } </button></div></article> ` ) . join ( '' ) } </div> ` } ${ ! pending && ! items . length ? ` <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 } ) ; } ) ; } ) ; enhanceGallery ( ) ; guardDeviceButtons ( ) ; hydrateImages ( ) ; document . querySelectorAll ( '[data-show]' ) . forEach ( b => b . onclick = busy ( ( ) => { const x = items [ b . dataset . show ] ; if ( x . current ) return ; 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 ( ) ; } ) ; } ) ; } ;
render ( cached || [ ] , ! cached ) ;
try {
const items = state . tab === 'cloud' ? await api ( '/api/v1/devices/' + state . device + '/images' ) : await api ( '/api/v1/assets?limit=200&offset=' + ( state . assetOffset || 0 ) + '&origin=' + encodeURIComponent ( state . assetOrigin || 'all' ) + ( state . assetJob ? '&job_id=' + encodeURIComponent ( state . assetJob ) : '' ) ) ;
if ( epoch !== state . epoch ) return ; cache . set ( key , items ) ;
if ( ! cached || JSON . stringify ( cached ) !== JSON . stringify ( items ) ) render ( items ) ;
} catch ( error ) {
if ( epoch !== state . epoch ) return ; if ( ! state . token ) throw error ;
const status = $ ( '#gallery-list-status' ) ; status . hidden = false ; status . setAttribute ( 'role' , 'alert' ) ; status . textContent = error . message ;
$ ( '.gallery' ) ? . setAttribute ( 'aria-busy' , 'false' ) ;
}
} ,
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 ; } } ,