@@ -1,8 +1,7 @@
'use strict' ;
'use strict' ;
const $ = s => document . querySelector ( s ) ;
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 : rememberedToken ( ) , device : 'living-room' , settings : null , devices : [ ] , templates : [ ] , gallery : [ ] , assets : [ ] , blobs : [ ] , tab : 'cloud' , preview : null , templatePreview : null , selectedTemplate : null , epoch : 0 } ;
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 rememberedToken ( ) { try { const saved = JSON . parse ( localStorage . getItem ( 'ff-login' ) || 'null' ) ; if ( saved ? . token && saved . expires _at > Date . now ( ) ) return saved . token ; } catch { } localStorage . removeItem ( 'ff-login' ) ; return '' ; }
function forgetLogin ( ) { localStorage . removeItem ( 'ff-login' ) ; state . token = '' ; state . csrf = '' ; state . settings = null ; state . devices = [ ] ; state . templates = [ ] ; }
function forgetLogin ( ) { localStorage . removeItem ( 'ff-login' ) ; state . token = '' ; state . settings = null ; state . devices = [ ] ; state . templates = [ ] ; }
async function loadConnection ( ) { state . settings = await api ( '/api/v1/settings' ) ; try { state . devices = await api ( '/api/v1/devices' ) ; } catch ( error ) { state . devices = [ ] ; toast ( error . message ) ; } state . templates = await api ( '/api/v1/templates' ) ; state . device = state . devices [ 0 ] ? . id || state . device ; }
async function loadConnection ( ) { state . settings = await api ( '/api/v1/settings' ) ; try { state . devices = await api ( '/api/v1/devices' ) ; } catch ( error ) { state . devices = [ ] ; toast ( error . message ) ; } state . templates = await api ( '/api/v1/templates' ) ; state . device = state . devices [ 0 ] ? . id || state . device ; }
const icons = { dashboard : '◫' , gallery : '▧' , upload : '↥' , templates : '▦' , automation : '◷' , playlists : '▤' , history : '↺' , settings : '⚙' } ;
const icons = { dashboard : '◫' , gallery : '▧' , upload : '↥' , templates : '▦' , automation : '◷' , playlists : '▤' , history : '↺' , settings : '⚙' } ;
const t = k => window . MESSAGES [ state . lang ] [ k ] || k ;
const t = k => window . MESSAGES [ state . lang ] [ k ] || k ;
@@ -12,7 +11,7 @@ const buttons=(id,text,primary=false)=>`<button id="${id}" class="${primary?'pri
let toastTimer ;
let toastTimer ;
function toast ( message ) { $ ( '#toast' ) . textContent = message ; $ ( '#toast' ) . style . display = 'block' ; clearTimeout ( toastTimer ) ; toastTimer = setTimeout ( ( ) => $ ( '#toast' ) . style . display = 'none' , 6000 ) ; }
function toast ( message ) { $ ( '#toast' ) . textContent = message ; $ ( '#toast' ) . style . display = 'block' ; clearTimeout ( toastTimer ) ; toastTimer = setTimeout ( ( ) => $ ( '#toast' ) . style . display = 'none' , 6000 ) ; }
async function api ( path , options = { } ) {
async function api ( path , options = { } ) {
const headers = { 'Authorization' : 'Bearer ' + state . token , ... options . headers } ;
const headers = { ... ( state . csrf ? { 'X-CSRF-Token' : state . csrf } : { } ) , ... options . headers } ;
if ( options . body && ! ( options . body instanceof FormData ) ) { headers [ 'Content-Type' ] = 'application/json' ; options . body = JSON . stringify ( options . body ) ; }
if ( options . body && ! ( options . body instanceof FormData ) ) { headers [ 'Content-Type' ] = 'application/json' ; options . body = JSON . stringify ( options . body ) ; }
const response = await fetch ( path , { ... options , headers } ) ;
const response = await fetch ( path , { ... options , headers } ) ;
if ( ! response . ok ) { if ( response . status === 401 ) forgetLogin ( ) ; let error ; try { error = ( await response . json ( ) ) . error ; } catch { }
if ( ! response . ok ) { if ( response . status === 401 ) forgetLogin ( ) ; let error ; try { error = ( await response . json ( ) ) . error ; } catch { }
@@ -31,9 +30,9 @@ function notice(){if(!state.settings)return '';return `<div class="notice ${!sta
async function navigate ( page = state . page ) { state . page = icons [ page ] ? page : 'dashboard' ; location . hash = state . page ; shell ( ) ; clearBlobs ( ) ; state . epoch ++ ; const epoch = state . epoch ;
async function navigate ( page = state . page ) { state . page = icons [ page ] ? page : 'dashboard' ; location . hash = state . page ; shell ( ) ; clearBlobs ( ) ; state . epoch ++ ; const epoch = state . epoch ;
if ( ! state . token ) { login ( ) ; return ; }
if ( ! state . token ) { login ( ) ; return ; }
$ ( '#main' ) . innerHTML = '<div class="spinner"></div>' ;
$ ( '#main' ) . innerHTML = '<div class="spinner"></div>' ;
try { await views [ state . page ] ( epoch ) ; } catch ( e ) { if ( epoch !== state . epoch ) return ; if ( ! state . token ) { shell ( ) ; login ( ) ; toast ( e . message ) ; return ; } $ ( '#main' ) . innerHTML = ` <div class="card empty"><strong> ${ t ( 'apiError' ) } </strong><p> ${ esc ( e . message ) } </p> ${ buttons ( 'retry' , 'refresh' ) } </div> ` ; bind ( 'retry' , ( ) => navigate ( ) ) ; }
try { await views [ state . page ] ( epoch ) ; guardDeviceButtons ( ) ; } catch ( e ) { if ( epoch !== state . epoch ) return ; if ( ! state . token ) { shell ( ) ; login ( ) ; toast ( e . message ) ; return ; } $ ( '#main' ) . innerHTML = ` <div class="card empty"><strong> ${ t ( 'apiError' ) } </strong><p> ${ esc ( e . message ) } </p> ${ buttons ( 'retry' , 'refresh' ) } </div> ` ; bind ( 'retry' , ( ) => navigate ( ) ) ; }
}
}
function login ( ) { $ ( '#main' ) . innerHTML = ` <div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px"> ${ t ( 'connect' ) } </h2><p class="hint"> ${ t ( 'tokenHint' ) } </p><form id="login-form"><div class="field"><label for="api-token "> ${ t ( 'token ' ) } </label><input id="api-token " type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked> ${ t ( 'rememberLogin' ) } </label><button class="primary" type="submit"> ${ t ( 'connect' ) } </button></form></div> ` ; $ ( '#login-form' ) . onsubmit = busy ( async e => { e . preventDefault ( ) ; state . token = $ ( '# api-token' ) . value . trim ( ) ; const remember = $ ( '#remember-login' ) . checked ; try { await loadConnection ( ) ; if ( remember ) localStorage . setItem ( 'ff-login' , JSON . stringify ( { token : state . token , expires _at : Date . now ( ) + 30 * 86400000 } ) ) ; else localStorage . removeItem ( 'ff-login' ) ; await navigate ( ) ; } catch ( error ) { forgetLogin ( ) ; shell ( ) ; login ( ) ; toast ( error . message ) ; } } ) ; }
function login ( ) { $ ( '#main' ) . innerHTML = ` <div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px"> ${ t ( 'connect' ) } </h2><p class="hint"> ${ t ( 'tokenHint' ) } </p><form id="login-form"><div class="field"><label for="username "> ${ t ( 'username ' ) } </label><input id="username" required autocomplete="username"></div><div class="field"><label for="password"> ${ t ( 'password' ) } </label><input id="password " type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked>${ t ( 'rememberLogin' ) } </label><button class="primary" type="submit"> ${ t ( 'connect' ) } </button></form></div> ` ; $ ( '#login-form' ) . onsubmit = busy ( async e => { e . preventDefault ( ) ; try { const account = await api ( '/ api/v1/auth/login' , { method : 'POST' , body : { username : $ ( '#username' ) . value . trim ( ) , password : $ ( '#password' ) . value , remember : $ ( '#remember-login' ) . checked } } ) ; state . token = account . username ; state . csrf = account . csrf _token ; await loadConnection ( ) ; await navigate ( ) ; } catch ( error ) { forgetLogin ( ) ; shell ( ) ; login ( ) ; toast ( error . message ) ; } } ) ; }
// LAN HTTP is not a secure context; getRandomValues remains available there.
// LAN HTTP is not a secure context; getRandomValues remains available there.
function clientUUID ( ) { if ( typeof crypto . randomUUID === 'function' ) return crypto . randomUUID ( ) ; const bytes = crypto . getRandomValues ( new Uint8Array ( 16 ) ) ; bytes [ 6 ] = ( bytes [ 6 ] & 15 ) | 64 ; bytes [ 8 ] = ( bytes [ 8 ] & 63 ) | 128 ; const hex = Array . from ( bytes , b => b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ; return ` ${ hex . slice ( 0 , 8 ) } - ${ hex . slice ( 8 , 12 ) } - ${ hex . slice ( 12 , 16 ) } - ${ hex . slice ( 16 , 20 ) } - ${ hex . slice ( 20 ) } ` ; }
function clientUUID ( ) { if ( typeof crypto . randomUUID === 'function' ) return crypto . randomUUID ( ) ; const bytes = crypto . getRandomValues ( new Uint8Array ( 16 ) ) ; bytes [ 6 ] = ( bytes [ 6 ] & 15 ) | 64 ; bytes [ 8 ] = ( bytes [ 8 ] & 63 ) | 128 ; const hex = Array . from ( bytes , b => b . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ; return ` ${ hex . slice ( 0 , 8 ) } - ${ hex . slice ( 8 , 12 ) } - ${ hex . slice ( 12 , 16 ) } - ${ hex . slice ( 16 , 20 ) } - ${ hex . slice ( 20 ) } ` ; }
async function accepted ( path , payload ) { const key = 'web-' + clientUUID ( ) ; const result = await api ( path , { method : 'POST' , body : payload , headers : { 'Idempotency-Key' : key } } ) ; toast ( t ( 'accepted' ) ) ; return result . operation _id ; }
async function accepted ( path , payload ) { const key = 'web-' + clientUUID ( ) ; const result = await api ( path , { method : 'POST' , body : payload , headers : { 'Idempotency-Key' : key } } ) ; toast ( t ( 'accepted' ) ) ; return result . operation _id ; }
@@ -41,19 +40,33 @@ async function waitOperation(id){for(let n=0;n<120;n++){const operation=await ap
function showModal ( html ) { $ ( '#modal-content' ) . innerHTML = html ; $ ( '#modal' ) . showModal ( ) ; bind ( 'modal-cancel' , ( ) => $ ( '#modal' ) . close ( ) ) ; }
function showModal ( html ) { $ ( '#modal-content' ) . innerHTML = html ; $ ( '#modal' ) . showModal ( ) ; bind ( 'modal-cancel' , ( ) => $ ( '#modal' ) . close ( ) ) ; }
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 ( ) ; } ) ; }
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> ` ; }
function detail ( label , value ) { return ` <div><small> ${ t ( label ) } </small><strong> ${ esc ( value ) } </strong></div> ` ; }
function liveWriteAllowed ( ) { return state . settings ? . mock || state . settings ? . write _enabled ; }
function guardDeviceButtons ( ) { if ( ! liveWriteAllowed ( ) ) document . querySelectorAll ( '[data-show],' + ( state . tab === 'cloud' ? '.image-card .danger,' : '' ) + '#show-name,#display-template,#display-upload,#restore' ) . forEach ( b => { b . disabled = true ; b . title = t ( 'readOnlyWarning' ) ; } ) ; }
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' ] : [ ] ) ] ) ] ;
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' ) : k === 'weather' ? ( 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 ( k === 'weather' ) 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 ) ; } ;
}
function accountSettings ( ) { const section = $ ( '#logout' ) . parentElement ; const form = document . createElement ( 'form' ) ; form . id = 'password-form' ; form . innerHTML = ` <h3> ${ t ( 'changePassword' ) } </h3><div class="field"><label for="current-password"> ${ t ( 'currentPassword' ) } </label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password"> ${ t ( 'newPassword' ) } </label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit"> ${ t ( 'changePassword' ) } </button> ` ; section . append ( form ) ; form . onsubmit = busy ( async e => { e . preventDefault ( ) ; await api ( '/api/v1/auth/password' , { method : 'PUT' , body : { current _password : $ ( '#current-password' ) . value , new _password : $ ( '#new-password' ) . value } } ) ; forgetLogin ( ) ; shell ( ) ; login ( ) ; toast ( t ( 'passwordChanged' ) ) ; } ) ; }
const views = {
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 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 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-' + 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 = 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 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 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 } ; 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' , ( ) => accepted ( '/api/v1/display' , { device _id : state . device , content : { type : 'image' , asset _id : state . templatePreview } } ) ) ; await templateEditor ( selected ) ; } ,
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 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 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 ( ) ; } ) ; } ) ) ; } ,
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 ( ) ; } ) ; } ) ) ; } ,
async settings ( ) { state . settings = await api ( '/api/v1/settings' ) ; const s = state . settings ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="columns"><section class="card"><h3> ${ t ( 'settings' ) } </h3><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><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' , ( ) => { forgetLogin ( ) ; shell ( ) ; login ( ) ; } ) ; }
async settings ( ) { state . settings = await api ( '/api/v1/settings' ) ; const s = state . settings ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="columns"><section class="card"><h3> ${ t ( 'settings' ) } </h3><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><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 , ... changes } ; }
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 , ... changes } ; }
function jobEditor ( job ) { const j = job || { name : '' , enabled : true , device _id : state . device , schedule : { type : 'daily' , time : '07:00' } , source : { type : 'template' , template : 'daily-dashboard' , data : { } } , display _duration : 5400 , restore _previous : true , priority : 50 } ; showModal ( ` <h2> ${ t ( job ? 'edit' : 'newJob' ) } </h2><form id="job-form"><div class="field"><label for="job-name"> ${ t ( 'name' ) } </label><input id="job-name" required maxlength="120" value=" ${ esc ( j . name ) } "></div><div class="form-grid"><div class="field"><label for="job-schedule"> ${ t ( 'schedule' ) } </label><select id="job-schedule"> ${ [ 'daily' , 'weekdays' , 'weekends' , 'cron' , 'date' , 'temporary' ] . map ( x => ` <option ${ j . schedule . type === x ? 'selected' : '' } value=" ${ x } "> ${ t ( x ) } </option> ` ) . join ( '' ) } </select></div><div class="field"><label id="schedule-label" for="schedule-value"> ${ t ( 'time' ) } </label><input id="schedule-value" required value=" ${ esc ( j . schedule . time || j . schedule . cron || ( j . schedule . at ? j . schedule . at . slice ( 0 , 16 ) : '07:00' ) ) } "></div></div><div class="field"><label for="job-source"> ${ t ( 'source' ) } (JSON)</label><textarea id="job-source"> ${ esc ( JSON . stringify ( j . source , null , 2 ) ) } </textarea></div><div class="form-grid"><div class="field"><label for="job-duration"> ${ t ( 'duration' ) } </label><input id="job-duration" type="number" min="0" max="604800" value=" ${ j . display _duration } "></div><div class="field"><label for="job-priority"> ${ t ( 'priority' ) } </label><input id="job-priority" type="number" min="0" max="100" value=" ${ j . priority } "></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${ j . enabled ? 'checked' : '' } > ${ t ( 'enabled' ) } </label><label class="check-row"><input type="checkbox" id="job-restore" ${ j . restore _previous ? 'checked' : '' } > ${ t ( 'restoreAfter' ) } </label><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form> ` ) ; function update ( ) { const type = $ ( '#job-schedule' ) . value ; $ ( '#schedule-label' ) . textContent = t ( type === 'cron' ? 'cron' : [ 'date' , 'temporary' ] . includes ( type ) ? 'at' : 'time' ) ; $ ( '#schedule-value' ) . type = type === 'cron' ? 'text' : [ 'date' , 'temporary' ] . includes ( type ) ? 'datetime-local' : 'time' ; } $ ( '#job-schedule' ) . onchange = ( ) => { update ( ) ; $ ( '#schedule-value' ) . value = '' ; } ; update ( ) ; $ ( '#job-form' ) . onsubmit = busy ( async e => { e . preventDefault ( ) ; const type = $ ( '#job-schedule' ) . value , value = $ ( '#schedule-value' ) . value ; const schedule = { type } ; if ( type === 'cron' ) schedule . cron = value ; else if ( [ 'date' , 'temporary' ] . includes ( type ) ) schedule . at = value + ':00+08:00' ; else schedule . time = value ; const payload = jobPayload ( j , { name : $ ( '#job-name' ) . value , schedule , source : JSON . parse ( $ ( '#job-source' ) . value ) , display _duration : Number ( $ ( '#job-duration' ) . value ) , priority : Number ( $ ( '#job-priority' ) . value ) , restore _previous : $ ( '#job-restore' ) . checked , enabled : $ ( '#job-enabled' ) . checked } ) ; await api ( '/api/v1/jobs' + ( job ? '/' + job . id : '' ) , { method : job ? 'PUT' : 'POST' , body : payload } ) ; $ ( '#modal' ) . close ( ) ; await navigate ( ) ; } ) ; }
function jobEditor ( job ) { const j = job || { name : '' , enabled : true , device _id : state . device , schedule : { type : 'daily' , time : '07:00' } , source : { type : 'template' , template : 'daily-dashboard' , data : { } } , display _duration : 5400 , restore _previous : true , priority : 50 } ; showModal ( ` <h2> ${ t ( job ? 'edit' : 'newJob' ) } </h2><form id="job-form"><div class="field"><label for="job-name"> ${ t ( 'name' ) } </label><input id="job-name" required maxlength="120" value=" ${ esc ( j . name ) } "></div><div class="form-grid"><div class="field"><label for="job-schedule"> ${ t ( 'schedule' ) } </label><select id="job-schedule"> ${ [ 'daily' , 'weekdays' , 'weekends' , 'cron' , 'date' , 'temporary' ] . map ( x => ` <option ${ j . schedule . type === x ? 'selected' : '' } value=" ${ x } "> ${ t ( x ) } </option> ` ) . join ( '' ) } </select></div><div class="field"><label id="schedule-label" for="schedule-value"> ${ t ( 'time' ) } </label><input id="schedule-value" required value=" ${ esc ( j . schedule . time || j . schedule . cron || ( j . schedule . at ? j . schedule . at . slice ( 0 , 16 ) : '07:00' ) ) } "></div></div><div class="field"><label for="job-source"> ${ t ( 'source' ) } (JSON)</label><textarea id="job-source"> ${ esc ( JSON . stringify ( j . source , null , 2 ) ) } </textarea></div><div class="form-grid"><div class="field"><label for="job-duration"> ${ t ( 'duration' ) } </label><input id="job-duration" type="number" min="0" max="604800" value=" ${ j . display _duration } "></div><div class="field"><label for="job-priority"> ${ t ( 'priority' ) } </label><input id="job-priority" type="number" min="0" max="100" value=" ${ j . priority } "></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${ j . enabled ? 'checked' : '' } > ${ t ( 'enabled' ) } </label><label class="check-row"><input type="checkbox" id="job-restore" ${ j . restore _previous ? 'checked' : '' } > ${ t ( 'restoreAfter' ) } </label><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form> ` ) ; const source = $ ( '#job-source' ) ; const choose = document . createElement ( 'div' ) ; choose . className = 'field' ; choose . innerHTML = ` <label for="job-template"> ${ t ( 'template' ) } </label><select id="job-template"><option value=""> ${ t ( 'advanced' ) } </option> ${ state . templates . map ( x => ` <option value=" ${ esc ( x . id ) } " ${ j . source . template === x . id ? 'selected' : '' } > ${ esc ( x . name ) } </option> ` ) . join ( '' ) } </select> ` ; source . parentElement . before ( choose ) ; $ ( '#job-template' ) . onchange = ( ) => { const id = $ ( '#job-template' ) . value ; if ( id ) source . value = JSON . stringify ( { type : 'template' , template : id , data : { } } , null , 2 ) ; } ; function update ( ) { const type = $ ( '#job-schedule' ) . value ; $ ( '#schedule-label' ) . textContent = t ( type === 'cron' ? 'cron' : [ 'date' , 'temporary' ] . includes ( type ) ? 'at' : 'time' ) ; $ ( '#schedule-value' ) . type = type === 'cron' ? 'text' : [ 'date' , 'temporary' ] . includes ( type ) ? 'datetime-local' : 'time' ; } $ ( '#job-schedule' ) . onchange = ( ) => { update ( ) ; $ ( '#schedule-value' ) . value = '' ; } ; update ( ) ; $ ( '#job-form' ) . onsubmit = busy ( async e => { e . preventDefault ( ) ; const type = $ ( '#job-schedule' ) . value , value = $ ( '#schedule-value' ) . value ; const schedule = { type } ; if ( type === 'cron' ) schedule . cron = value ; else if ( [ 'date' , 'temporary' ] . includes ( type ) ) schedule . at = value + ':00+08:00' ; else schedule . time = value ; const payload = jobPayload ( j , { name : $ ( '#job-name' ) . value , schedule , source : JSON . parse ( $ ( '#job-source' ) . value ) , display _duration : Number ( $ ( '#job-duration' ) . value ) , priority : Number ( $ ( '#job-priority' ) . value ) , restore _previous : $ ( '#job-restore' ) . checked , enabled : $ ( '#job-enabled' ) . checked } ) ; await api ( '/api/v1/jobs' + ( job ? '/' + job . id : '' ) , { method : job ? 'PUT' : 'POST' , body : payload } ) ; $ ( '#modal' ) . close ( ) ; await navigate ( ) ; } ) ; }
function playlistEditor ( p ) { p = p || { name : '' , image _ids : [ ] , interval _minutes : 60 , mode : 'queue' , enabled : false } ; showModal ( ` <h2> ${ t ( 'playlists' ) } </h2><form id="playlist-form"><div class="field"><label for="playlist-name"> ${ t ( 'name' ) } </label><input id="playlist-name" required maxlength="120" value=" ${ esc ( p . name ) } "></div><div class="field"><label for="playlist-images"> ${ t ( 'imageIds' ) } </label><input id="playlist-images" required value=" ${ esc ( p . image _ids . join ( ',' ) ) } " placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval"> ${ t ( 'interval' ) } </label><input id="playlist-interval" type="number" min="5" max="10080" value=" ${ p . interval _minutes } "></div><div class="field"><label for="playlist-mode"> ${ t ( 'mode' ) } </label><select id="playlist-mode"><option value="queue" ${ p . mode === 'queue' ? 'selected' : '' } > ${ t ( 'queue' ) } </option><option value="random" ${ p . mode === 'random' ? 'selected' : '' } > ${ t ( 'random' ) } </option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${ p . enabled ? 'checked' : '' } > ${ t ( 'enabled' ) } </label><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form> ` ) ; $ ( '#playlist-form' ) . onsubmit = busy ( async e => { e . preventDefault ( ) ; await api ( '/api/v1/playlists' + ( p . id ? '/' + p . id : '' ) , { method : p . id ? 'PUT' : 'POST' , body : { name : $ ( '#playlist-name' ) . value , device _id : p . device _id || state . device , image _ids : $ ( '#playlist-images' ) . value . split ( ',' ) . map ( x => Number ( x . trim ( ) ) ) , interval _minutes : Number ( $ ( '#playlist-interval' ) . value ) , mode : $ ( '#playlist-mode' ) . value , enabled : $ ( '#playlist-enabled' ) . checked } } ) ; $ ( '#modal' ) . close ( ) ; await navigate ( ) ; } ) ; }
function playlistEditor ( p ) { p = p || { name : '' , image _ids : [ ] , interval _minutes : 60 , mode : 'queue' , enabled : false } ; showModal ( ` <h2> ${ t ( 'playlists' ) } </h2><form id="playlist-form"><div class="field"><label for="playlist-name"> ${ t ( 'name' ) } </label><input id="playlist-name" required maxlength="120" value=" ${ esc ( p . name ) } "></div><div class="field"><label for="playlist-images"> ${ t ( 'imageIds' ) } </label><input id="playlist-images" required value=" ${ esc ( p . image _ids . join ( ',' ) ) } " placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval"> ${ t ( 'interval' ) } </label><input id="playlist-interval" type="number" min="5" max="10080" value=" ${ p . interval _minutes } "></div><div class="field"><label for="playlist-mode"> ${ t ( 'mode' ) } </label><select id="playlist-mode"><option value="queue" ${ p . mode === 'queue' ? 'selected' : '' } > ${ t ( 'queue' ) } </option><option value="random" ${ p . mode === 'random' ? 'selected' : '' } > ${ t ( 'random' ) } </option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${ p . enabled ? 'checked' : '' } > ${ t ( 'enabled' ) } </label><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form> ` ) ; $ ( '#playlist-form' ) . onsubmit = busy ( async e => { e . preventDefault ( ) ; await api ( '/api/v1/playlists' + ( p . id ? '/' + p . id : '' ) , { method : p . id ? 'PUT' : 'POST' , body : { name : $ ( '#playlist-name' ) . value , device _id : p . device _id || state . device , image _ids : $ ( '#playlist-images' ) . value . split ( ',' ) . map ( x => Number ( x . trim ( ) ) ) , interval _minutes : Number ( $ ( '#playlist-interval' ) . value ) , mode : $ ( '#playlist-mode' ) . value , enabled : $ ( '#playlist-enabled' ) . checked } } ) ; $ ( '#modal' ) . close ( ) ; await navigate ( ) ; } ) ; }
$ ( '#lang' ) . onclick = ( ) => { state . lang = state . lang === 'zh' ? 'en' : 'zh' ; localStorage . setItem ( 'ff-language' , state . lang ) ; navigate ( ) ; } ; $ ( '#theme' ) . onclick = ( ) => { state . theme = document . documentElement . dataset . theme === 'dark' ? 'light' : 'dark' ; localStorage . setItem ( 'ff-theme' , state . theme ) ; applyTheme ( ) ; } ; $ ( '#device' ) . onchange = ( ) => { state . device = $ ( '#device' ) . value ; navigate ( ) ; } ; window . addEventListener ( 'hashchange' , ( ) => { const page = location . hash . slice ( 1 ) ; if ( page !== state . page ) navigate ( page ) ; } ) ; matchMedia ( '(prefers-color-scheme:dark)' ) . addEventListener ( 'change' , applyTheme ) ;
$ ( '#lang' ) . onclick = ( ) => { state . lang = state . lang === 'zh' ? 'en' : 'zh' ; localStorage . setItem ( 'ff-language' , state . lang ) ; navigate ( ) ; } ; $ ( '#theme' ) . onclick = ( ) => { state . theme = document . documentElement . dataset . theme === 'dark' ? 'light' : 'dark' ; localStorage . setItem ( 'ff-theme' , state . theme ) ; applyTheme ( ) ; } ; $ ( '#device' ) . onchange = ( ) => { state . device = $ ( '#device' ) . value ; navigate ( ) ; } ; window . addEventListener ( 'hashchange' , ( ) => { const page = location . hash . slice ( 1 ) ; if ( page !== state . page ) navigate ( page ) ; } ) ; matchMedia ( '(prefers-color-scheme:dark)' ) . addEventListener ( 'change' , applyTheme ) ;
applyTheme ( ) ; ( async ( ) => { if ( state . token ) { try { await loadConnection ( ) ; } catch ( error ) { forgetLogin ( ) ; toast ( error . message ) ; } } await navigate ( ) ; } ) ( ) ; api ( '/api/v1/version' ) . then ( v => $ ( '#footer' ) . innerHTML = ` <span>Pigway Frame Flow · ${ t ( 'footer' ) } </span><span>v ${ esc ( v . version ) } · ${ esc ( v . commit . slice ( 0 , 8 ) ) } · ${ esc ( v . build _time ) } </span> ` ) . catch ( ( ) => { } ) ;
applyTheme ( ) ; localStorage . removeItem ( 'ff-login' ) ; ( async ( ) => { try { const account = await api ( '/api/v1/auth/me' ) ; state . token = account . username ; state . csrf = account . csrf _token ; await loadConnection ( ) ; } catch { forgetLogin ( ) ; } await navigate ( ) ; } ) ( ) ; api ( '/api/v1/version' ) . then ( v => $ ( '#footer' ) . innerHTML = ` <span>Pigway Frame Flow · ${ t ( 'footer' ) } </span><span>v ${ esc ( v . version ) } · ${ esc ( v . commit . slice ( 0 , 8 ) ) } · ${ esc ( v . build _time ) } </span> ` ) . catch ( ( ) => { } ) ;
setInterval ( ( ) => { if ( state . token && state . page === 'dashboard' && ! $ ( '#modal' ) . open ) navigate ( ) ; } , 60000 ) ;
setInterval ( ( ) => { if ( state . token && state . page === 'dashboard' && ! $ ( '#modal' ) . open ) navigate ( ) ; } , 60000 ) ;