2026-10-02 00:03:48 +08:00
'use strict' ;
const $ = s => document . querySelector ( s );
2026-10-02 08:43:07 +08:00
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 (){ localStorage . removeItem ( 'ff-login' ); state . token = '' ; state . csrf = '' ; state . settings = null ; state . devices = []; state . templates = [];}
2026-10-02 02:23:24 +08:00
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 ;}
2026-10-05 09:09:18 +08:00
const icons = { dashboard : '◫' , gallery : '▧' , 'photo-sources' : '▣' , upload : '↥' , templates : '▦' , automation : '◷' , playlists : '▤' , history : '↺' , settings : '⚙' };
2026-10-02 00:03:48 +08:00
const t = k => window . MESSAGES [ state . lang ][ k ] || k ;
const esc = v => String ( v ?? '—' ). replace ( /[&<>"']/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ c ]));
const fmt = v => v ? new Date ( v ). toLocaleString ( state . lang === 'zh' ? 'zh-CN' : 'en-GB' ,{ timeZone : 'Asia/Shanghai' }) : '—' ;
2026-10-05 19:59:10 +08:00
const padTime = ( hour , minute )=> ` ${ String ( hour ). padStart ( 2 , '0' ) } : ${ String ( minute ). padStart ( 2 , '0' ) } ` ;
function normaliseSchedule ( schedule ){ const copy = {... schedule }; if ( copy . type !== 'cron' ) return copy ; const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/ . exec ( copy . cron || '' ); if ( ! match ) return copy ; const type = match [ 3 ] === '*' ? 'daily' : match [ 3 ] === '1-5' ? 'weekdays' : 'weekends' ; return { type , time : padTime ( match [ 2 ], match [ 1 ])};}
function scheduleTypeLabel ( type ){ if ( type !== 'cron' ) return t ( type ); return state . lang === 'zh' ? '高级规则(Cron) ' : 'Advanced rule (Cron)' ;}
function scheduleSummary ( schedule ){ const value = normaliseSchedule ( schedule ); if ([ 'daily' , 'weekdays' , 'weekends' ]. includes ( value . type )) return ` ${ scheduleTypeLabel ( value . type ) } · ${ value . time } ( ${ state . lang === 'zh' ? '上海时间' : 'Shanghai time' } ) `; if ( value . type === 'cron' ) return ` ${ scheduleTypeLabel ( 'cron' ) } · ${ value . cron } ` ; return ` ${ scheduleTypeLabel ( value . type ) } · ${ fmt ( value . at ) } ` ;}
function scheduleHint ( type ){ if ( type === 'cron' ) return state . lang === 'zh' ? '仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。' : 'Use only for special rules; choose Daily for a regular time.' ; if ([ 'date' , 'temporary' ]. includes ( type )) return state . lang === 'zh' ? '选择一次性执行的日期和时间。' : 'Choose the date and time for this one-time task.' ; return state . lang === 'zh' ? '选择执行时间(上海时区)。' : 'Choose a time in Shanghai time.' ;}
2026-10-02 14:52:57 +08:00
const buttons = ( id , text , primary = false )=> `<button type="button" id=" ${ id } " class=" ${ primary ? 'primary' : '' } "> ${ t ( text ) } </button>` ;
2026-10-02 00:03:48 +08:00
let toastTimer ;
function toast ( message ){ $ ( '#toast' ). textContent = message ; $ ( '#toast' ). style . display = 'block' ; clearTimeout ( toastTimer ); toastTimer = setTimeout (()=> $ ( '#toast' ). style . display = 'none' , 6000 );}
async function api ( path , options = {}){
2026-10-02 08:43:07 +08:00
const headers = {...( state . csrf ? { 'X-CSRF-Token' : state . csrf } : {}),... options . headers };
2026-10-02 00:03:48 +08:00
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 });
2026-10-05 00:21:17 +08:00
if ( ! response . ok ){ let error ; try { error = ( await response . json ()). error ;} catch {} if ( response . status === 401 && error ? . code !== 'OFFICIAL_AUTH_REQUIRED' ) forgetLogin ();
2026-10-02 00:03:48 +08:00
const e = new Error (( error ? . code || response . status ) + ': ' + ( error ? . code && t ( error . code ) !== error . code ? t ( error . code ) : ( error ? . message || t ( 'error' )))); e . code = error ? . code ; throw e ;}
if ( response . status === 204 ) return null ;
return options . binary ? await response . blob () : await response . json ();
}
function bind ( id , fn ){ const element = $ ( '#' + id ); if ( element ) element . onclick = async event =>{ try { element . disabled = true ; await fn ( event );} catch ( e ){ toast ( e . message );} finally { element . disabled = false ;}};}
function busy ( fn ){ return async event =>{ try { await fn ( event );} catch ( e ){ toast ( e . message );}};}
function applyTheme (){ const dark = state . theme === 'dark' || ( state . theme === 'system' && matchMedia ( '(prefers-color-scheme:dark)' ). matches ); document . documentElement . dataset . theme = dark ? 'dark' : 'light' ;}
2026-10-05 00:37:07 +08:00
function shell (){ document . documentElement . lang = state . lang === 'zh' ? 'zh-CN' : 'en' ; $ ( '#lang' ). textContent = state . lang === 'zh' ? 'EN' : '中文' ; $ ( '#theme' ). title = t ( 'theme' ); $ ( '#nav' ). innerHTML = Object . entries ( icons ). map (([ id , icon ])=> `<button data-page=" ${ id } " class=" ${ id === state . page ? 'active' : '' } "><span class="nav-icon"> ${ icon } </span><span> ${ t ( id ) } </span></button>` ). join ( '' ); document . querySelectorAll ( '[data-page]' ). forEach ( b => b . onclick = ()=> navigate ( b . dataset . page )); $ ( '#page-title' ). textContent = t ( state . page ); $ ( '#device' ). innerHTML = ( state . devices . length ? state . devices : [{ id : state . device , name : state . device }]). map ( d => `<option value=" ${ esc ( d . id ) } " ${ d . id === state . device ? 'selected' : '' } > ${ esc ( d . name ) } </option>` ). join ( '' );}
2026-10-02 00:03:48 +08:00
function clearBlobs (){ for ( const url of state . blobs ) URL . revokeObjectURL ( url ); state . blobs = [];}
async function blobImage ( img , path ){ try { const blob = await api ( path ,{ binary : true }); if ( ! img . isConnected ) return ; const url = URL . createObjectURL ( blob ); state . blobs . push ( url ); img . src = url ;} catch { img . alt = t ( 'apiError' );}}
function hydrateImages (){ const observer = new IntersectionObserver ( entries => entries . forEach ( entry =>{ if ( entry . isIntersecting ){ observer . unobserve ( entry . target ); blobImage ( entry . target , entry . target . dataset . src );}}),{ rootMargin : '100px' }); document . querySelectorAll ( 'img[data-src]' ). forEach ( img => observer . observe ( img ));}
2026-10-05 00:37:07 +08:00
function notice (){ return state . settings ? . mock ? `<div class="notice"> ${ t ( 'mock' ) } </div>` : '' ;}
2026-10-02 00:03:48 +08:00
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 ;}
$ ( '#main' ). innerHTML = '<div class="spinner"></div>' ;
2026-10-02 09:19:25 +08:00
try { await views [ state . page ]( epoch ); if ( epoch !== state . epoch ) return ; const enhance = { dashboard : enhanceDashboard , templates : enhanceTemplates , gallery : enhanceGallery , settings : enhanceSettings , upload : enhanceUpload , history : enhanceHistory , automation : enhanceAutomation }[ state . page ]; if ( enhance ) await enhance (); 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 ());}
2026-10-02 00:03:48 +08:00
}
2026-10-02 08:43:07 +08:00
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 );}});}
2026-10-02 01:11:17 +08:00
// 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 ) } ` ;}
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 ;}
2026-10-02 00:03:48 +08:00
async function waitOperation ( id ){ for ( let n = 0 ; n < 120 ; n ++ ){ const operation = await api ( '/api/v1/operations/' + id ); if ( operation . status === 'COMPLETED' ) return operation ; if ([ 'FAILED' , 'UNKNOWN' ]. includes ( operation . status )) throw new Error ( operation . status === 'UNKNOWN' ? t ( 'unknown' ) : ( operation . error ? . message || t ( 'error' ))); await new Promise ( resolve => setTimeout ( resolve , 2000 ));} throw new Error ( t ( 'syncing' ));}
2026-10-08 19:40:05 +08:00
function showModal ( html ){
const dialog = $ ( '#modal' ), content = $ ( '#modal-content' ); content . innerHTML = html ;
2026-10-08 20:07:57 +08:00
const image = content . querySelector ( 'img' ); dialog . classList . toggle ( 'image-viewer' , !! image ); document . body . classList . toggle ( 'image-viewer-open' , !! image ); dialog . onclose = ()=> document . body . classList . remove ( 'image-viewer-open' );
2026-10-08 19:40:05 +08:00
if ( image ){
const viewport = document . createElement ( 'div' ); viewport . className = 'image-viewer-viewport' ; image . before ( viewport ); viewport . append ( image );
const fit = document . createElement ( 'button' ), original = document . createElement ( 'button' ); fit . textContent = t ( 'fitScreen' ); original . textContent = t ( 'actualSize' );
2026-10-08 20:41:54 +08:00
fit . type = original . type = 'button' ; fit . setAttribute ( 'aria-pressed' , 'true' ); original . setAttribute ( 'aria-pressed' , 'false' );
const footer = document . createElement ( 'div' ); footer . className = 'image-viewer-footer' ;
const title = content . querySelector ( 'h2' ); if ( title ){ title . classList . add ( 'image-viewer-name' ); footer . append ( title );}
const actions = content . querySelector ( '.modal-actions' ) || document . createElement ( 'div' ); actions . classList . add ( 'modal-actions' , 'image-viewer-actions' );
const cancel = actions . querySelector ( '#modal-cancel' ); if ( cancel ){ cancel . before ( fit , original ); actions . append ( cancel );} else { actions . append ( fit , original );}
footer . append ( actions ); content . append ( footer );
2026-10-08 19:40:05 +08:00
const setSize = actual =>{ viewport . classList . toggle ( 'actual-size' , actual ); fit . setAttribute ( 'aria-pressed' , String ( ! actual )); original . setAttribute ( 'aria-pressed' , String ( actual ));};
fit . onclick = ()=> setSize ( false ); original . onclick = ()=> setSize ( true );
}
dialog . showModal (); bind ( 'modal-cancel' ,()=> dialog . close ());
}
2026-10-02 00:03:48 +08:00
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>` ;}
2026-10-02 08:43:07 +08:00
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 ){
2026-10-05 00:31:56 +08:00
const raw = $ ( '#template-data' ); const assets = await api ( '/api/v1/assets?source=uploaded&limit=200' ); let values = JSON . parse ( raw . value );
2026-10-02 08:43:07 +08:00
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)' };
2026-10-08 21:49:54 +08:00
const fieldHelp = { title : 'helpTitle' , message : 'helpMessage' , quote : 'helpQuote' , author : 'helpAuthor' , events : 'helpEvents' , date : 'helpDate' , weekday : 'helpWeekday' , weather : 'helpWeather' , poem_title : 'helpPoemTitle' , poem_author : 'helpPoemAuthor' , poem_lines : 'helpPoemLines' };
2026-10-02 09:57:42 +08:00
const fields = [... new Set ([ 'title' ,... selected . fields . filter ( k => Object . hasOwn ( selected . defaults , k )),...([ 'daily-dashboard' , 'calendar' ]. includes ( selected . id ) ? [ 'events' , 'date' , 'weekday' ] : selected . id === 'daily-poetry' ? [ 'date' ] : []),...( selected . id === 'weather' || selected . id === 'daily-dashboard' ? [ 'weather' ] : []),...([ 'photo-text' , 'daily-dashboard' ]. includes ( selected . id ) ? [ 'photo_id' ] : [])])];
2026-10-08 21:49:54 +08:00
function invalidate (){ state . templatePreview = null ; $ ( '#display-template' ). disabled = true ; $ ( '#template-preview' ). className = 'empty' ; $ ( '#template-preview' ). textContent = t ( 'templatePreviewHint' );}
function build (){ panel . innerHTML = `<h3> ${ t ( 'formEditor' ) } </h3><p class="hint"> ${ t ( 'templateEditHelp' ) } </p>` + 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> ${ values . photo_id &&! assets . some ( a => a . asset_id === values . photo_id ) ? `<option value=" ${ esc ( values . photo_id ) } " selected disabled> ${ state . lang === 'zh' ? '已有选择不在上传照片列表中,请重新选择' : 'Previous selection is outside uploaded photos; choose again' } </option>` : '' }${ assets . map ( a => `<option value=" ${ esc ( a . asset_id ) } " ${ a . asset_id === values . photo_id ? 'selected' : '' } > ${ esc ( a . name ) } </option>` ). join ( '' ) } </select><p class="hint"> ${ state . lang === 'zh' ? '只列出上传的图片,不包含模板生成图。没有照片时,请使用下方文件选择器上传。' : 'Uploaded images only; generated templates are excluded. Upload a photo below if needed.' } </p></div>` : `<div class="field"><label for="field- ${ k } "> ${ esc ( labels [ k ] || t ( k )) } </label><textarea id="field- ${ k } " data-field=" ${ k } " ${ fieldHelp [ k ] ? `aria-describedby="help- ${ k } "` : '' } rows=" ${ [ 'events' , 'message' , 'weather' , 'quote' ]. includes ( k ) ? 3 : 1 } "> ${ esc ( k === 'events' ? ( values [ k ] || []). map ( v => typeof v === 'object' ? v . title : v ). join ( '\n' ) : [ 'weather' , 'forecast' ]. includes ( k ) ? ( values [ k ] ? JSON . stringify ( values [ k ], null , 2 ) : '' ) : values [ k ] || '' ) } </textarea> ${ fieldHelp [ k ] ? `<p class="hint" id="help- ${ k } "> ${ t ( fieldHelp [ k ]) } </p>` : '' } </div>` ). join ( '' ); panel . querySelectorAll ( '[data-field]' ). forEach ( el => el . oninput = ()=>{ try { const k = el . dataset . field ; let v = el . value ; if ( k === 'events' ) v = v . split ( '\n' ). filter ( x => x . trim ()); if ([ 'weather' , 'forecast' ]. includes ( k )) v = v . trim () ? JSON . parse ( v ) : null ; if ( v === null || v === '' ) delete values [ k ]; else values [ k ] = v ; raw . value = JSON . stringify ( values , null , 2 ); el . setCustomValidity ( '' ); invalidate ();} catch { el . setCustomValidity ( t ( 'INVALID_REQUEST' )); el . reportValidity ();}});}
2026-10-02 08:43:07 +08:00
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' ));});}
2026-10-02 00:03:48 +08:00
const views = {
2026-10-05 09:09:18 +08:00
'photo-sources' : async ()=>{ $ ( '#main' ). innerHTML = '' ; await photoSourcesPanel (); },
2026-10-02 00:11:37 +08:00
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' );};},
2026-10-06 09:31:09 +08:00
async gallery ( epoch ){ let 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 ; 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><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 });});}); 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 ();});});},
2026-10-02 09:19:25 +08:00
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 }));},
2026-10-08 21:49:54 +08:00
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 ();}; 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 ;}},
2026-10-02 00:03:48 +08:00
async automation ( epoch ){ const jobs = await api ( '/api/v1/jobs' ); if ( epoch !== state . epoch ) return ; $ ( '#main' ). innerHTML = notice () + `<div class="section-heading"><p class="hint">Asia/Shanghai · ${ t ( 'automation' ) } </p> ${ buttons ( 'new-job' , 'newJob' , true ) } </div><div class="card"> ${ jobs . length ? `<div class="table-wrap"><table><thead><tr> ${ [ 'name' , 'schedule' , 'status' , 'nextRun' , 'lastRun' , 'result' , 'action' ]. map ( x => `<th> ${ t ( x ) } </th>` ). join ( '' ) } </tr></thead><tbody> ${ jobs . map (( j , i )=> `<tr><td><strong> ${ esc ( j . name ) } </strong><br><small> ${ esc ( j . device_id ) } </small></td><td> ${ t ( j . schedule . type ) } <br> ${ esc ( j . schedule . time || j . schedule . cron || fmt ( j . schedule . at )) } </td><td><span class="badge"> ${ t ( j . enabled ? 'enabled' : 'disabled' ) } </span></td><td> ${ fmt ( j . next_run ) } </td><td> ${ fmt ( j . last_run ) } </td><td><code> ${ esc ( j . last_result ) } </code></td><td><div class="row-actions"><button data-job-edit=" ${ i } "> ${ t ( 'edit' ) } </button><button data-job-toggle=" ${ i } "> ${ t ( j . enabled ? 'disabled' : 'enabled' ) } </button><button class="danger" data-job-delete=" ${ i } "> ${ t ( 'remove' ) } </button></div></td></tr>` ). join ( '' ) } </tbody></table></div>` : `<div class="empty"><strong> ${ t ( 'noTasks' ) } </strong></div>` } </div>` ; bind ( 'new-job' ,()=> jobEditor ()); document . querySelectorAll ( '[data-job-edit]' ). forEach ( b => b . onclick = ()=> jobEditor ( jobs [ b . dataset . jobEdit ])); document . querySelectorAll ( '[data-job-toggle]' ). forEach ( b => b . onclick = busy ( async ()=>{ const j = jobs [ b . dataset . jobToggle ]; await api ( '/api/v1/jobs/' + j . id ,{ method : 'PUT' , body : jobPayload ( j ,{ enabled :! j . enabled })}); await navigate ();})); document . querySelectorAll ( '[data-job-delete]' ). forEach ( b => b . onclick = ()=> confirmDialog ( t ( 'removeConfirm' ), async ()=>{ await api ( '/api/v1/jobs/' + jobs [ b . dataset . jobDelete ]. id ,{ method : 'DELETE' }); await navigate ();}));},
async playlists ( epoch ){ const playlists = await api ( '/api/v1/playlists' ); if ( epoch !== state . epoch ) return ; $ ( '#main' ). innerHTML = notice () + `<div class="notice"> ${ t ( 'playlistNotice' ) } </div><div class="section-heading"><h2> ${ t ( 'playlists' ) } </h2> ${ buttons ( 'new-playlist' , 'newPlaylist' , true ) } </div><div class="gallery"> ${ playlists . map (( p , i )=> `<section class="card"><span class="badge"> ${ t ( p . enabled ? 'enabled' : 'disabled' ) } </span><h3 style="margin-top:16px"> ${ esc ( p . name ) } </h3><p class="hint"> ${ p . image_ids . length } ${ t ( 'count' ) } · ${ p . interval_minutes } ${ t ( 'minutes' ) } · ${ t ( p . mode ) } </p><p><code> ${ esc ( p . image_ids . join ( ' → ' )) } </code></p><small> ${ t ( 'lastScheduled' ) } : ${ p . position >= 0 ? esc ( p . image_ids [ p . position ]) : '—' } </small><div class="actions" style="margin-top:16px"><button data-pl-edit=" ${ i } "> ${ t ( 'edit' ) } </button><button class="danger" data-pl-delete=" ${ i } "> ${ t ( 'remove' ) } </button></div></section>` ). join ( '' ) || `<div class="card empty"><strong> ${ t ( 'noTasks' ) } </strong></div>` } </div>` ; bind ( 'new-playlist' ,()=> playlistEditor ()); document . querySelectorAll ( '[data-pl-edit]' ). forEach ( b => b . onclick = ()=> playlistEditor ( playlists [ b . dataset . plEdit ])); document . querySelectorAll ( '[data-pl-delete]' ). forEach ( b => b . onclick = ()=> confirmDialog ( t ( 'removeConfirm' ), async ()=>{ await api ( '/api/v1/playlists/' + playlists [ b . dataset . plDelete ]. id ,{ method : 'DELETE' }); await navigate ();}));},
async history ( epoch ){ const ops = await api ( '/api/v1/operations' ); if ( epoch !== state . epoch ) return ; $ ( '#main' ). innerHTML = `<div class="section-heading"><h2> ${ t ( 'history' ) } </h2> ${ buttons ( 'history-refresh' , 'refresh' ) } </div> ${ ops . some ( x => x . status === 'UNKNOWN' ) ? `<div class="notice warning"> ${ t ( 'unknown' ) } </div>` : '' } <div class="card table-wrap"> ${ ops . length ? `<table><thead><tr> ${ [ 'created' , 'action' , 'status' , 'result' , 'client' , 'requestId' ]. map ( x => `<th> ${ t ( x ) } </th>` ). join ( '' ) } </tr></thead><tbody> ${ ops . map (( o , i )=> `<tr><td> ${ fmt ( o . created_at ) } </td><td> ${ esc ( o . action ) } <br><small> ${ esc ( o . device_id ) } </small></td><td><span class="badge ${ [ 'FAILED' , 'UNKNOWN' ]. includes ( o . status ) ? 'bad' : '' } "> ${ t ( o . status ) } </span> ${ o . status === 'UNKNOWN' ? `<div><button data-reconcile=" ${ i } "> ${ t ( 'reconcile' ) } </button></div>` : '' } </td><td> ${ esc ( o . result ? . image_id || o . result ? . asset_id || o . error ? . code || '—' ) } </td><td> ${ esc ( o . client_id ) } </td><td><code> ${ esc ( o . request_id ) } <br> ${ esc ( o . operation_id ) } </code></td></tr>` ). join ( '' ) } </tbody></table>` : `<div class="empty"> ${ t ( 'noHistory' ) } </div>` } </div>` ; bind ( 'history-refresh' ,()=> navigate ()); document . querySelectorAll ( '[data-reconcile]' ). forEach ( b => b . onclick = busy ( async ()=>{ const o = ops [ b . dataset . reconcile ]; const r = await api ( '/api/v1/operations/' + o . operation_id + '/reconcile' ,{ method : 'POST' , body : { acknowledge : false }}); showModal ( `<h2> ${ t ( 'reconcile' ) } </h2><p> ${ t ( 'current' ) } : ${ esc ( r . current_image_id ) } </p><p> ${ t ( 'result' ) } : ${ esc ( r . expected_image_id ) } </p><p class="hint"> ${ t ( 'unknown' ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) }${ r . resolved ? '' : buttons ( 'acknowledge' , 'acknowledge' ) } </div>` ); bind ( 'acknowledge' , async ()=>{ await api ( '/api/v1/operations/' + o . operation_id + '/reconcile' ,{ method : 'POST' , body : { acknowledge : true }}); $ ( '#modal' ). close (); await navigate ();});}));},
2026-10-02 08:43:07 +08:00
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 ();}
2026-10-02 00:03:48 +08:00
};
2026-10-06 09:10:36 +08:00
function jobPayload ( j , changes = {}){ return { name : j . name , enabled : j . enabled , device_id : j . device_id , schedule : j . schedule , source : j . source , display_duration : j . display_duration , restore_previous : j . restore_previous , priority : j . priority , cleanup_unstarred : j . cleanup_unstarred || false ,... changes };}
2026-10-06 09:31:09 +08:00
async 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 , cleanup_unstarred : false , enabled : $ ( '#job-enabled' ). checked }); await api ( '/api/v1/jobs' + ( job ? '/' + job . id : '' ),{ method : job ? 'PUT' : 'POST' , body : payload }); $ ( '#modal' ). close (); await navigate ();}); await jobSourceEditor ( j );}
2026-10-02 09:19:25 +08:00
async 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 ();}); await playlistImagePicker ( p );}
2026-10-02 00:03:48 +08:00
$ ( '#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 );
2026-10-02 08:43:07 +08:00
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 (()=>{});
2026-10-02 00:03:48 +08:00
setInterval (()=>{ if ( state . token && state . page === 'dashboard' &&! $ ( '#modal' ). open ) navigate ();}, 60000 );
2026-10-05 19:59:10 +08:00
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
async function jobEditor ( job ){
const base = { 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 };
const original = job || base ;
const j = {... base ,... original , schedule : normaliseSchedule ( original . schedule || base . schedule )};
const scheduleValue = j . schedule . time || j . schedule . cron || ( j . schedule . at ? j . schedule . at . slice ( 0 , 16 ) : '07:00' );
2026-10-06 09:31:09 +08:00
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' , 'date' , 'temporary' , 'cron' ]. map ( type => `<option value=" ${ type } " ${ j . schedule . type === type ? 'selected' : '' } > ${ scheduleTypeLabel ( type ) } </option>` ). join ( '' ) } </select></div><div class="field"><label id="schedule-label" for="schedule-value"> ${ t ( 'time' ) } </label><input id="schedule-value" required value=" ${ esc ( scheduleValue ) } "></div></div><p class="hint" id="schedule-hint"></p><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><p class="hint"> ${ t ( 'cloudKeepLatest' ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form>` );
2026-10-05 19:59:10 +08:00
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 ( template => `<option value=" ${ esc ( template . id ) } " ${ j . source . template === template . id ? 'selected' : '' } > ${ esc ( template . 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 updateScheduleField (){ const type = $ ( '#job-schedule' ). value ; $ ( '#schedule-label' ). textContent = type === 'cron' ? scheduleTypeLabel ( type ) : t ([ 'date' , 'temporary' ]. includes ( type ) ? 'at' : 'time' ); $ ( '#schedule-value' ). type = type === 'cron' ? 'text' : [ 'date' , 'temporary' ]. includes ( type ) ? 'datetime-local' : 'time' ; $ ( '#schedule-hint' ). textContent = scheduleHint ( type );}
$ ( '#job-schedule' ). onchange = ()=>{ updateScheduleField (); $ ( '#schedule-value' ). value = '' ;};
updateScheduleField ();
2026-10-06 09:31:09 +08:00
$ ( '#job-form' ). onsubmit = busy ( async event =>{ event . preventDefault (); const type = $ ( '#job-schedule' ). value ; const 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 ( source . value ), display_duration : Number ( $ ( '#job-duration' ). value ), priority : Number ( $ ( '#job-priority' ). value ), restore_previous : $ ( '#job-restore' ). checked , cleanup_unstarred : false , enabled : $ ( '#job-enabled' ). checked }); await api ( '/api/v1/jobs' + ( job ? '/' + job . id : '' ),{ method : job ? 'PUT' : 'POST' , body : payload }); $ ( '#modal' ). close (); await navigate ();});
2026-10-05 19:59:10 +08:00
await jobSourceEditor ( j );
}
views . automation = async 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 ( key => `<th> ${ t ( key ) } </th>` ). join ( '' ) } </tr></thead><tbody> ${ jobs . map (( job , index )=> `<tr><td><strong> ${ esc ( job . name ) } </strong><br><small> ${ esc ( job . device_id ) } </small></td><td> ${ esc ( scheduleSummary ( job . schedule )) } </td><td><span class="badge"> ${ t ( job . enabled ? 'enabled' : 'disabled' ) } </span></td><td> ${ fmt ( job . next_run ) } </td><td> ${ fmt ( job . last_run ) } </td><td><code> ${ esc ( job . last_result ) } </code></td><td><div class="row-actions"><button data-job-edit=" ${ index } "> ${ t ( 'edit' ) } </button><button data-job-toggle=" ${ index } "> ${ t ( job . enabled ? 'disabled' : 'enabled' ) } </button><button class="danger" data-job-delete=" ${ index } "> ${ 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 ( button => button . onclick = ()=> jobEditor ( jobs [ button . dataset . jobEdit ]));
document . querySelectorAll ( '[data-job-toggle]' ). forEach ( button => button . onclick = busy ( async ()=>{ const job = jobs [ button . dataset . jobToggle ]; await api ( '/api/v1/jobs/' + job . id ,{ method : 'PUT' , body : jobPayload ( job ,{ enabled :! job . enabled })}); await navigate ();}));
document . querySelectorAll ( '[data-job-delete]' ). forEach ( button => button . onclick = ()=> confirmDialog ( t ( 'removeConfirm' ), async ()=>{ await api ( '/api/v1/jobs/' + jobs [ button . dataset . jobDelete ]. id ,{ method : 'DELETE' }); await navigate ();}));
};