2026-10-02 09:19:25 +08:00
'use strict' ;
// Page features share the same authenticated API and display operation pipeline.
async function currentPanel ( container ){
2026-10-10 08:36:23 +08:00
const epoch = state . epoch ;
2026-10-02 09:19:25 +08:00
const content = await api ( '/api/v1/devices/' + state . device + '/current-content' );
2026-10-10 08:36:23 +08:00
if ( epoch !== state . epoch ||! container . isConnected ) return content ;
2026-10-02 09:19:25 +08:00
state . currentDisplay = content ;
const known = content . source_known && content . source ? . type === 'template' ;
container . innerHTML = `<h3> ${ t ( 'currentContent' ) } </h3><p># ${ esc ( content . image_id ?? '—' ) } · ${ t ( content . synced ? 'synced' : 'syncing' ) } </p><p> ${ known ? ` ${ t ( 'currentTemplate' ) } : <strong> ${ esc ( content . source . template ) } </strong>` : content . source_known ? t ( 'sourceKnown' ) : t ( 'sourceUnknown' ) } </p> ${ known ? buttons ( 'edit-current-template' , 'editCurrent' ) : '' }${ content . overrides . length ? `<div class="notice"><strong> ${ t ( 'temporaryDisplay' ) } </strong> ${ content . overrides . map ( x => `<p># ${ esc ( x . image_id ) } · ${ t ( 'priority' ) } : ${ x . priority } · ${ fmt ( x . expires_at ) } </p>` ). join ( '' ) } </div>` : '' } ` ;
bind ( 'edit-current-template' ,()=> editTemplateSource ( content . source ));
return content ;
}
function editTemplateSource ( source ){ state . selectedTemplate = source . template ; state . pendingTemplateData = structuredClone ( source ); return navigate ( 'templates' );}
async function enhanceDashboard (){
const epoch = state . epoch ; const frame = $ ( '.frame-demo' ); const panel = document . createElement ( 'section' ); panel . className = 'card' ; $ ( '#main' ). append ( panel );
const current = await currentPanel ( panel ); if ( epoch !== state . epoch ) return ;
if ( current . preview_url ){ frame . innerHTML = `<img id="current-frame-image" alt=" ${ t ( 'currentContent' ) } " style="width:100%;height:100%;object-fit:contain"><span class="frame-label"> ${ t ( 'currentContent' ) } # ${ current . image_id } </span>` ; await blobImage ( $ ( '#current-frame-image' ), current . preview_url );}
const weather = await api ( '/api/v1/providers/weather' ); if ( epoch !== state . epoch ) return ; const box = document . createElement ( 'section' ); box . className = 'card' ; box . innerHTML = `<h3> ${ t ( 'weather' ) } </h3><p> ${ esc ( weather . current . location ) } · ${ esc ( weather . current . temperature ) } · ${ esc ( weather . current . summary ) } </p><p class="hint"> ${ weather . current . mock ? t ( 'weatherMock' ) : t ( 'weatherAttribution' ) }${ weather . current . stale ? ' · ' + t ( 'stale' ) : '' } </p>` ; $ ( '#main' ). append ( box );
}
async function enhanceTemplates (){
const panel = document . createElement ( 'section' ); panel . className = 'card' ; $ ( '.template-grid' ). before ( panel ); await currentPanel ( panel ); if ( ! panel . isConnected ) return ;
2026-10-02 14:52:57 +08:00
const download = document . createElement ( 'button' ); download . id = 'download-template' ; download . textContent = t ( 'download' ); const previewActions = document . createElement ( 'div' ); previewActions . className = 'actions template-preview-actions' ; $ ( '#display-template' ). before ( previewActions ); previewActions . append ( $ ( '#display-template' ), download );
2026-10-02 09:19:25 +08:00
download . onclick = busy ( async ()=>{ if ( ! state . templatePreview ){ toast ( t ( 'previewHint' )); return ;} await downloadImage ( '/api/v1/assets/' + state . templatePreview , 'frame-flow-template.jpg' );});
2026-10-02 09:57:42 +08:00
await poetryControls ();
2026-10-02 14:52:57 +08:00
officialContentInfo ();
2026-10-02 09:19:25 +08:00
const photo = $ ( '#field-photo_id' ); if ( photo ){ const file = document . createElement ( 'input' ); file . type = 'file' ; file . id = 'template-photo-upload' ; file . accept = 'image/jpeg,image/png,image/webp' ; file . setAttribute ( 'aria-label' , t ( 'selectFile' )); photo . after ( file ); file . onchange = busy ( async ()=>{ if ( ! file . files [ 0 ]) return ; const body = new FormData (); body . append ( 'file' , file . files [ 0 ]); body . append ( 'fit' , 'cover' ); body . append ( 'profile' , 'color' ); const asset = await api ( '/api/v1/preview' ,{ method : 'POST' , body }); const accepted = await api ( '/api/v1/devices/' + state . device + '/upload' ,{ method : 'POST' , body : { type : 'image' , asset_id : asset . asset_id }, headers : { 'Idempotency-Key' : 'photo-' + clientUUID ()}}); await waitOperation ( accepted . operation_id ); const option = document . createElement ( 'option' ); option . value = asset . asset_id ; option . textContent = asset . name ; photo . append ( option ); photo . value = asset . asset_id ; photo . oninput (); toast ( t ( 'done' ));});}
}
async function downloadImage ( path , name ){ const blob = await api ( path ,{ binary : true }); const url = URL . createObjectURL ( blob ); const link = document . createElement ( 'a' ); link . href = url ; link . download = name ; document . body . append ( link ); link . click (); link . remove (); setTimeout (()=> URL . revokeObjectURL ( url ), 10000 );}
function displayDialog ( content ){
if ( ! liveWriteAllowed ()){ toast ( t ( 'readOnlyWarning' )); return ;}
showModal ( `<h2> ${ t ( 'displayOptions' ) } </h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox"> ${ t ( 'restoreAfter' ) } </label><div class="form-grid"><div class="field"><label for="override-duration"> ${ t ( 'duration' ) } </label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority"> ${ t ( 'priority' ) } </label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button id="confirm-display" type="submit" class="primary"> ${ t ( 'display' ) } </button></div></form>` );
$ ( '#display-options' ). onsubmit = busy ( async e =>{ e . preventDefault (); const restore = $ ( '#override-restore' ). checked ; const options = { duration : restore ? Number ( $ ( '#override-duration' ). value ) : 0 , restore_previous : restore , priority : Number ( $ ( '#override-priority' ). value )}; await accepted ( '/api/v1/display' ,{ device_id : state . device , content , options }); $ ( '#modal' ). close (); await navigate ( 'history' );});
}
2026-10-05 00:52:39 +08:00
async function enhanceGallery (){
2026-10-02 09:19:25 +08:00
const grid = $ ( '.gallery' ); if ( ! grid ) return ;
const controls = document . createElement ( 'div' ); controls . className = 'form-grid' ; controls . innerHTML = `<div class="field"><label for="gallery-search"> ${ t ( 'searchImages' ) } </label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort"> ${ t ( 'mode' ) } </label><select id="gallery-sort"><option value="newest"> ${ t ( 'sortNewest' ) } </option><option value="oldest"> ${ t ( 'sortOldest' ) } </option><option value="name"> ${ t ( 'sortName' ) } </option></select></div>` ; grid . before ( controls );
2026-10-06 09:31:09 +08:00
if ( state . tab === 'local' ){ const bar = document . createElement ( 'div' ); bar . className = 'actions' ; bar . innerHTML = `<select id="asset-origin" aria-label=" ${ t ( 'originFilter' ) } "> ${ [ 'all' , 'manual' , 'task' , 'external' , 'template' , 'unknown' ]. map ( x => `<option value=" ${ x } "> ${ t ( 'origin_' + x ) } </option>` ). join ( '' ) } </select><select id="asset-job" aria-label=" ${ t ( 'taskOrigin' ) } "><option value=""> ${ t ( 'allTasks' ) } </option></select><button id="asset-prev"> ${ t ( 'previousPage' ) } </button><button id="asset-next"> ${ t ( 'nextPage' ) } </button><button id="asset-all"> ${ t ( 'selectAll' ) } </button><button id="asset-batch-star"> ${ t ( 'star' ) } </button><button id="asset-batch-unstar"> ${ t ( 'unstar' ) } </button><button id="asset-batch-delete" class="danger"> ${ t ( 'deleteSelected' ) } </button>` ; grid . before ( bar ); $ ( '#asset-origin' ). value = state . assetOrigin || 'all' ; const jobs = await api ( '/api/v1/jobs' ); for ( const job of jobs ){ const option = document . createElement ( 'option' ); option . value = job . id ; option . textContent = job . name ; $ ( '#asset-job' ). append ( option );} $ ( '#asset-job' ). value = state . assetJob || '' ; $ ( '#asset-origin' ). onchange = ()=>{ state . assetOffset = 0 ; state . assetOrigin = $ ( '#asset-origin' ). value ; return navigate ();}; $ ( '#asset-job' ). onchange = ()=>{ state . assetOffset = 0 ; state . assetJob = $ ( '#asset-job' ). value ; state . assetOrigin = state . assetJob ? 'task' : state . assetOrigin ; return navigate ();}; $ ( '#asset-prev' ). disabled =! ( state . assetOffset || 0 ); $ ( '#asset-next' ). disabled = state . gallery . length < 200 ; bind ( 'asset-prev' ,()=>{ state . assetOffset = Math . max ( 0 ,( state . assetOffset || 0 ) - 200 ); return navigate ();}); bind ( 'asset-next' ,()=>{ state . assetOffset = ( state . assetOffset || 0 ) + 200 ; return navigate ();}); $ ( '#asset-all' ). onclick = ()=>{ const boxes = [... document . querySelectorAll ( '.asset-select' )]. filter ( x => ! x . closest ( '.image-card' ). hidden ); const checked = boxes . some ( x => ! x . checked ); boxes . forEach ( x => x . checked = checked );}; for ( const action of [ 'star' , 'unstar' , 'delete' ]){ bind ( 'asset-batch-' + action , async ()=>{ const ids = [... document . querySelectorAll ( '.asset-select:checked' )]. map ( x => x . dataset . asset ); if ( ! ids . length ){ toast ( t ( 'selectImagesFirst' )); return ;} if ( action === 'delete' &&! confirm ( t ( 'deleteSelectedConfirm' ))) return ; const results = []; for ( let i = 0 ; i < ids . length ; i += 100 ){ const r = await api ( '/api/v1/assets/batch' ,{ method : 'POST' , body : { asset_ids : ids . slice ( i , i + 100 ), action }}); results . push (... r . results );} const failed = results . filter ( x => x . status === 'failed' ); if ( failed . length ) toast ( t ( 'batchFailed' ) + ' ' + failed . length ); await navigate ();});}}
2026-10-02 09:19:25 +08:00
const cards = [... grid . querySelectorAll ( '.image-card' )]; $ ( '#gallery-search' ). oninput = ()=>{ const query = $ ( '#gallery-search' ). value . toLowerCase (); cards . forEach ( card => card . hidden =! card . textContent . toLowerCase (). includes ( query ));}; $ ( '#gallery-sort' ). onchange = ()=>{ const order = $ ( '#gallery-sort' ). value ; const indexes = cards . map (( card , index )=>({ card , item : state . gallery [ index ]})); indexes . sort (( a , b )=> order === 'name' ? a . item . name . localeCompare ( b . item . name ) : order === 'oldest' ? String ( a . item . created_at ). localeCompare ( String ( b . item . created_at )) : String ( b . item . created_at ). localeCompare ( String ( a . item . created_at ))); indexes . forEach ( x => grid . append ( x . card ));};
2026-10-06 09:31:09 +08:00
cards . forEach (( card , i )=>{ const item = state . gallery [ i ]; if ( state . tab === 'local' ){ const star = document . createElement ( 'button' ); star . textContent = t ( item . starred ? 'unstar' : 'star' ); star . setAttribute ( 'aria-pressed' , String ( item . starred )); card . querySelector ( '.actions' ). append ( star ); star . onclick = busy ( async ()=>{ await api ( '/api/v1/assets/' + item . asset_id + '/star' ,{ method : 'PUT' , body : { starred :! item . starred }}); await navigate ();}); const label = document . createElement ( 'small' ); label . textContent = item . origin === 'task' ? t ( 'taskOrigin' ) + ' · ' + ( item . job_name || item . job_id ) : t ( 'origin_' + item . origin ); card . querySelector ( 'h3' ). after ( label ); const select = document . createElement ( 'input' ); select . type = 'checkbox' ; select . className = 'asset-select' ; select . dataset . asset = item . asset_id ; select . setAttribute ( 'aria-label' , t ( 'selectImage' )); card . prepend ( select );} if ( item . width && item . height ){ const dimensions = document . createElement ( 'small' ); dimensions . textContent = item . width + ' × ' + item . height ; card . querySelector ( 'h3' ). after ( dimensions );} const download = document . createElement ( 'button' ); download . textContent = t ( 'download' ); card . querySelector ( '.actions' ). append ( download ); download . onclick = busy (()=> downloadImage ( item . url , item . name )); if ( state . tab === 'local' ){ const edit = document . createElement ( 'button' ); edit . textContent = t ( 'continueEdit' ); card . querySelector ( '.actions' ). append ( edit ); edit . onclick = busy ( async ()=>{ const meta = await api ( '/api/v1/assets/' + item . asset_id + '/source' ); if ( meta . source ? . type === 'template' ) await editTemplateSource ( meta . source ); else toast ( t ( 'sourceUnknown' ));});}});
2026-10-02 09:19:25 +08:00
}
async function weatherSettings (){
const weather = await api ( '/api/v1/providers/weather' ); const box = document . createElement ( 'section' ); box . className = 'card' ; box . innerHTML = `<h3> ${ t ( 'weatherSettings' ) } </h3><form id="weather-form"><div class="field"><label for="weather-provider">Provider</label><select id="weather-provider"><option value="mock"> ${ t ( 'weatherMock' ) } </option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location"> ${ t ( 'location' ) } </label><input id="weather-location" required maxlength="120" value=" ${ esc ( weather . config . location ) } "></div><div class="form-grid"> ${ [ 'latitude' , 'longitude' ]. map ( k => `<div class="field"><label for="weather- ${ k } "> ${ t ( k ) } </label><input id="weather- ${ k } " required type="number" step="any" min=" ${ k === 'latitude' ?- 90 :- 180 } " max=" ${ k === 'latitude' ? 90 : 180 } " value=" ${ weather . config [ k ] } "></div>` ). join ( '' ) } </div><button type="submit" class="primary"> ${ t ( 'save' ) } </button></form><div id="weather-result"></div>` ; $ ( '#main' ). append ( box ); $ ( '#weather-provider' ). value = weather . config . provider ;
function result ( w ){ $ ( '#weather-result' ). innerHTML = `<p> ${ esc ( w . current . temperature ) } · ${ esc ( w . current . summary ) } </p><p class="hint"> ${ w . current . mock ? t ( 'weatherMock' ) : t ( 'weatherAttribution' ) }${ w . current . stale ? ' · ' + t ( 'stale' ) : '' } </p><h4> ${ t ( 'forecast' ) } </h4> ${ w . forecast . map ( x => `<p> ${ esc ( x . date ) } · ${ esc ( x . summary ) } · ${ x . minimum_c } – ${ x . maximum_c } °C · ${ x . rain_probability } %</p>` ). join ( '' ) } ` ;}
result ( weather ); $ ( '#weather-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); await api ( '/api/v1/settings/weather' ,{ method : 'PUT' , body : { provider : $ ( '#weather-provider' ). value , location : $ ( '#weather-location' ). value , latitude : Number ( $ ( '#weather-latitude' ). value ), longitude : Number ( $ ( '#weather-longitude' ). value )}}); result ( await api ( '/api/v1/providers/weather' )); toast ( t ( 'done' ));});
}
async function enhanceHistory (){
2026-10-05 00:38:51 +08:00
const table = document . querySelector ( '#main table' ); if ( ! table ) return ; table . classList . add ( 'history-table' ); const heading = document . createElement ( 'th' ); heading . textContent = t ( 'operation' ); table . querySelector ( 'thead tr' ). append ( heading ); const rows = [... table . querySelectorAll ( 'tbody tr' )]; const ops = await api ( '/api/v1/operations' ); rows . forEach (( row , i )=>{ const op = ops [ i ]; if ( ! op ) return ; const button = document . createElement ( 'button' ); button . textContent = t ( 'details' ); const actions = document . createElement ( 'td' ); actions . className = 'history-actions' ; actions . append ( button ); row . append ( actions ); button . onclick = busy ( async ()=>{ const current = await api ( '/api/v1/operations/' + op . operation_id ); showModal ( `<h2> ${ t ( 'details' ) } </h2><p> ${ t ( current . status ) } · ${ esc ( current . action ) } </p><p> ${ esc ( current . error ? . message || '' ) } </p><pre style="white-space:pre-wrap;overflow-wrap:anywhere"> ${ esc ( JSON . stringify ({ operation_id : current . operation_id , request_id : current . request_id , created_at : current . created_at , updated_at : current . updated_at , result : current . result } ,null,2))}</pre> ${ current . result ? . url ? '<img id="operation-preview" class="preview" alt="Preview">' : '' } <div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } </div>` ); if ( current . result ? . url ) await blobImage ( $ ( '#operation-preview' ), current . result . url );});});}
2026-10-02 09:19:25 +08:00
async function enhanceAutomation (){
2026-10-10 09:28:37 +08:00
const jobs = await api ( '/api/v1/jobs' ); const rows = [... document . querySelectorAll ( 'tbody tr' )]; rows . forEach (( row , i )=>{ const job = jobs [ i ]; if ( ! job ) return ; const actions = row . querySelector ( '.row-actions' ); const run = document . createElement ( 'button' ); run . textContent = t ( 'runNow' ); run . disabled =! liveWriteAllowed (); run . title = run . disabled ? t ( 'readOnlyWarning' ) : '' ; actions . append ( run ); run . onclick = ()=> confirmDialog ( t ( 'runConfirm' ), async ()=>{ await accepted ( '/api/v1/jobs/' + job . id + '/run' ,{}); await navigate ( 'history' );}); if ([ 'template' , 'auto_collage' ]. includes ( job . source . type )){ const preview = document . createElement ( 'button' ); preview . textContent = t ( 'jobPreview' ); actions . append ( preview ); preview . onclick = busy ( async ()=>{ const id = await accepted ( '/api/v1/jobs/' + job . id + '/preview' ,{}); const op = await waitOperation ( id ); showModal ( `<h2> ${ t ( 'jobPreview' ) } </h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } </div>` ); await blobImage ( $ ( '#job-preview-image' ), op . result . url );});}});
2026-10-02 09:19:25 +08:00
}
async function jobSourceEditor ( job ){
const raw = $ ( '#job-source' ); const advanced = document . createElement ( 'details' ); advanced . innerHTML = `<summary> ${ t ( 'advanced' ) } </summary>` ; raw . parentElement . before ( advanced ); advanced . append ( raw . parentElement );
2026-10-05 00:52:39 +08:00
const box = document . createElement ( 'div' ); advanced . before ( box ); let source = structuredClone ( job . source ); let images = [], assets = [], photoSources = []; try {[ images , assets , photoSources ] = await Promise . all ([ api ( '/api/v1/devices/' + state . device + '/images' ), api ( '/api/v1/assets' ), api ( '/api/v1/photo-sources' )]);} catch ( e ){ toast ( e . message );} if ( ! raw . isConnected ) return ;
2026-10-10 09:28:37 +08:00
box . innerHTML = `<div class="field"><label for="job-content-type"> ${ t ( 'sourceType' ) } </label><select id="job-content-type"> ${ [ 'template' , 'image' , 'image_url' , 'photo_source' , 'auto_collage' , 'restore' ]. map ( k => `<option value=" ${ k } "> ${ k === 'auto_collage' ? ( state . lang === 'zh' ? '自动随机拼图' : 'Automatic collage' ) : k === 'template' ? t ( 'template' ) : k === 'image' ? t ( 'imageChoice' ) : k === 'restore' ? t ( 'restoreSource' ) : k === 'photo_source' ? t ( 'photoSources' ) : t ( 'imageURL' ) } </option>` ). join ( '' ) } </select></div><div id="job-content-fields"></div>` ;
2026-10-02 09:19:25 +08:00
$ ( '#job-content-type' ). value = source . type ; const old = $ ( '#job-template' ) ? . parentElement ; if ( old ) old . remove ();
function update (){ raw . value = JSON . stringify ( source , null , 2 );}
function fields (){ const type = $ ( '#job-content-type' ). value ; const panel = $ ( '#job-content-fields' ); if ( type === 'restore' ){ source = { type }; panel . innerHTML = '' ; $ ( '#job-restore' ). checked = false ; $ ( '#job-duration' ). value = 0 ; update (); return ;}
if ( type === 'template' ){ const selected = state . templates . find ( tpl => tpl . id === source . template ) || state . templates [ 0 ]; source = { type , template : selected . id , data : source . type === 'template' ? source . data || {} : {}, profile : source . profile || 'color' }; panel . innerHTML = `<div class="field"><label for="job-template"> ${ t ( 'template' ) } </label><select id="job-template"> ${ state . templates . map ( x => `<option value=" ${ x . id } " ${ selected . id === x . id ? 'selected' : '' } > ${ esc ( x . name ) } </option>` ). join ( '' ) } </select></div><div class="field"><label for="job-source-profile"> ${ t ( 'profile' ) } </label><select id="job-source-profile"><option value="color"> ${ t ( 'color' ) } </option><option value="grayscale"> ${ t ( 'grayscale' ) } </option></select></div><div id="job-data-fields"></div>` ; $ ( '#job-source-profile' ). value = source . profile ; $ ( '#job-source-profile' ). onchange = ()=>{ source . profile = $ ( '#job-source-profile' ). value ; update ();}; const defaults = {... selected . defaults ,... source . data }; const keys = [... new Set ([... Object . keys ( defaults ). filter ( k => typeof defaults [ k ] !== 'object' || Array . isArray ( defaults [ k ])),...([ 'calendar' , 'daily-dashboard' ]. includes ( selected . id ) ? [ 'events' ] : [])])]; $ ( '#job-data-fields' ). innerHTML = keys . map ( k => `<div class="field"><label for="job-field- ${ k } "> ${ esc ({ title : state . lang === 'zh' ? '标题' : 'Title' , message : state . lang === 'zh' ? '正文' : 'Message' , quote : state . lang === 'zh' ? '一句话' : 'Quote' , author : state . lang === 'zh' ? '作者' : 'Author' , events : t ( 'eventsHelp' ) } [k]||k)}</label><textarea id="job-field- ${ k } " data-job-field=" ${ k } " rows="2"> ${ esc ( Array . isArray ( defaults [ k ]) ? defaults [ k ]. map ( x => typeof x === 'object' ? x . title : x ). join ( '\n' ) : defaults [ k ] || '' ) } </textarea></div>` ). join ( '' ); $ ( '#job-template' ). onchange = ()=>{ source = { type : 'template' , template : $ ( '#job-template' ). value , data : {}}; fields ();}; panel . querySelectorAll ( '[data-job-field]' ). forEach ( el => el . oninput = ()=>{ source . data [ el . dataset . jobField ] = el . dataset . jobField === 'events' ? el . value . split ( '\n' ). filter ( x => x . trim ()) : el . value ; update ();}); update ();}
2026-10-10 09:28:37 +08:00
else if ( type === 'auto_collage' ){ source = { count : 4 , use_ai : true ,... source , type }; panel . innerHTML = `<div class="field"><label for="job-collage-source"> ${ t ( 'photoSources' ) } </label><select id="job-collage-source"><option value=""> ${ state . lang === 'zh' ? '本地手动上传照片' : 'Locally uploaded photos' } </option> ${ photoSources . filter ( x => x . enabled ). map ( x => `<option value=" ${ esc ( x . id ) } "> ${ esc ( x . name ) } </option>` ). join ( '' ) } </select></div><div class="field"><label for="job-collage-count"> ${ state . lang === 'zh' ? '拼图数量(1– 12) ' : 'Photo count (1– 12)' } </label><input id="job-collage-count" type="number" min="1" max="12" required value=" ${ source . count } "></div><div class="field"><label for="job-collage-title"> ${ state . lang === 'zh' ? '标题' : 'Title' } </label><input id="job-collage-title" maxlength="100" value=" ${ esc ( source . title || '' ) } "></div><div class="field"><label for="job-collage-prompt"> ${ state . lang === 'zh' ? '排版要求' : 'Layout instructions' } </label><textarea id="job-collage-prompt" maxlength="1500"> ${ esc ( source . prompt || '' ) } </textarea></div><label class="check-row"><input id="job-collage-ai" type="checkbox" ${ source . use_ai ? 'checked' : '' } > ${ state . lang === 'zh' ? 'AI 自动排版(需先配置连接中心)' : 'AI layout (configure Connections first)' } </label><p class="hint"> ${ state . lang === 'zh' ? '每次执行重新随机选图;取消 AI 使用自动网格。' : 'Each run picks new photos; uncheck AI for an automatic grid.' } </p>` ; $ ( '#job-collage-source' ). value = source . source_id || '' ; for ( const id of [ 'source' , 'count' , 'title' , 'prompt' , 'ai' ]) $ ( '#job-collage-' + id ). oninput = ()=>{ source = {... source , source_id : $ ( '#job-collage-source' ). value || null , count : Number ( $ ( '#job-collage-count' ). value ), title : $ ( '#job-collage-title' ). value , prompt : $ ( '#job-collage-prompt' ). value , use_ai : $ ( '#job-collage-ai' ). checked }; update ();}; update ();}
2026-10-02 09:19:25 +08:00
else if ( type === 'image' ){ const selected = source . image_id ? 'cloud:' + source . image_id : source . asset_id ? 'local:' + source . asset_id : '' ; panel . innerHTML = `<div class="field"><label for="job-image"> ${ t ( 'imageChoice' ) } </label><select id="job-image" required><option value="">—</option> ${ images . map ( i => `<option value="cloud: ${ i . image_id } "> ${ esc ( i . name ) } · # ${ i . image_id } </option>` ). join ( '' ) }${ assets . map ( a => `<option value="local: ${ a . asset_id } "> ${ esc ( a . name ) } · LOCAL</option>` ). join ( '' ) } </select></div>` ; $ ( '#job-image' ). value = selected ; $ ( '#job-image' ). onchange = ()=>{ const [ kind , id ] = $ ( '#job-image' ). value . split ( ':' ); source = kind === 'cloud' ? { type : 'image' , image_id : Number ( id )} : { type : 'image' , asset_id : id }; update ();};}
2026-10-05 00:52:39 +08:00
else if ( type === 'photo_source' ){ panel . innerHTML = `<div class="field"><label for="job-photo-source"> ${ t ( 'photoSources' ) } </label><select id="job-photo-source" required><option value="">—</option> ${ photoSources . map ( x => `<option value=" ${ esc ( x . id ) } "> ${ esc ( x . name ) } </option>` ). join ( '' ) } </select><p class="hint"> ${ t ( 'sourceJobHint' ) } </p></div>` ; $ ( '#job-photo-source' ). value = source . source_id || '' ; $ ( '#job-photo-source' ). onchange = ()=>{ source = { type : 'photo_source' , source_id : $ ( '#job-photo-source' ). value , fit : 'contain' , profile : 'color' }; update ();};}
2026-10-02 09:19:25 +08:00
else { panel . innerHTML = `<div class="field"><label for="job-url"> ${ t ( 'imageURL' ) } </label><input id="job-url" type="url" placeholder="https://…" value=" ${ esc ( source . type === 'image_url' ? source . url : '' ) } " required></div>` ; $ ( '#job-url' ). oninput = ()=>{ source = { type : 'image_url' , url : $ ( '#job-url' ). value }; update ();};}}
$ ( '#job-content-type' ). onchange = ()=>{ source = { type : $ ( '#job-content-type' ). value }; update (); fields ();}; raw . oninput = ()=>{ try { source = JSON . parse ( raw . value ); raw . setCustomValidity ( '' ); $ ( '#job-content-type' ). value = source . type ; fields ();} catch { raw . setCustomValidity ( t ( 'INVALID_REQUEST' ));}}; fields ();
}
async function playlistImagePicker ( playlist ){
const raw = $ ( '#playlist-images' ); const box = document . createElement ( 'div' ); raw . parentElement . after ( box ); box . innerHTML = `<p> ${ t ( 'imageChoice' ) } </p><div id="playlist-image-choices"> ${ t ( 'loading' ) } </div>` ; const images = await api ( '/api/v1/devices/' + state . device + '/images' ); if ( ! box . isConnected ) return ; const chosen = new Set ( playlist . image_ids );
$ ( '#playlist-image-choices' ). innerHTML = images . map ( image => `<label class="check-row"><input type="checkbox" data-playlist-image=" ${ image . image_id } " ${ chosen . has ( image . image_id ) ? 'checked' : '' } > ${ esc ( image . name ) } · # ${ image . image_id }${ image . current ? ' · ' + t ( 'current' ) : '' } </label>` ). join ( '' ); box . querySelectorAll ( '[data-playlist-image]' ). forEach ( el => el . onchange = ()=>{ const id = Number ( el . dataset . playlistImage ); if ( el . checked ) chosen . add ( id ); else chosen . delete ( id ); raw . value = [... chosen ]. join ( ',' );}); raw . oninput = ()=>{ chosen . clear (); raw . value . split ( ',' ). map ( Number ). filter ( x => x > 0 ). forEach ( x => chosen . add ( x )); box . querySelectorAll ( '[data-playlist-image]' ). forEach ( el => el . checked = chosen . has ( Number ( el . dataset . playlistImage )));};
}
async function enhanceSettings (){ await weatherSettings (); const config = await api ( '/api/v1/settings/image-url' ); const box = document . createElement ( 'section' ); box . className = 'card' ; box . innerHTML = `<h3> ${ t ( 'urlSettings' ) } </h3><form id="image-host-form"><div class="field"><label for="image-hosts"> ${ t ( 'allowedHosts' ) } </label><textarea id="image-hosts" rows="3"> ${ esc ( config . allowed_hosts . join ( '\n' )) } </textarea><p class="hint"> ${ t ( 'urlHint' ) } </p></div><button type="submit"> ${ t ( 'save' ) } </button></form>` ; $ ( '#main' ). append ( box ); $ ( '#image-host-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); await api ( '/api/v1/settings/image-url' ,{ method : 'PUT' , body : { allowed_hosts : $ ( '#image-hosts' ). value . split ( '\n' ). map ( x => x . trim ()). filter ( Boolean )}}); toast ( t ( 'done' ));});}
function enhanceUpload (){ const box = document . createElement ( 'div' ); box . innerHTML = `<h3> ${ t ( 'uploadURL' ) } </h3><div class="field"><label for="upload-url"> ${ t ( 'imageURL' ) } </label><input id="upload-url" type="url" placeholder="https://…"><p class="hint"> ${ t ( 'urlHint' ) } </p></div> ${ buttons ( 'preview-url' , 'uploadURL' ) } ` ; $ ( '#make-preview' ). parentElement . append ( box ); bind ( 'preview-url' , async ()=>{ if ( ! $ ( '#upload-url' ). checkValidity ()) return ; const id = await accepted ( '/api/v1/devices/' + state . device + '/upload' ,{ type : 'image_url' , url : $ ( '#upload-url' ). value , fit : $ ( '#fit' ). value , profile : $ ( '#profile' ). value }); const op = await waitOperation ( id ); state . preview = { asset_id : op . result . asset_id , url : op . result . url }; $ ( '#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 ();});}
2026-10-02 09:57:42 +08:00
async function poetryControls (){
if ( state . selectedTemplate !== 'daily-poetry' ) return ;
const provider = await api ( '/api/v1/providers/poetry' ); if ( ! $ ( '#template-fields' )) return ;
const box = document . createElement ( 'div' ); box . className = 'poetry-controls' ; box . innerHTML = `<div class="notice"> ${ t ( 'poetryNotice' ) } </div><div class="field"><label for="poem-choice"> ${ t ( 'poemChoice' ) } </label><select id="poem-choice"><option value=""> ${ t ( 'dailyAutomatic' ) } </option> ${ provider . catalog . map ( p => `<option value=" ${ p . id } "> ${ esc ( p . title ) } · ${ esc ( p . author ) } </option>` ). join ( '' ) } </select></div><p id="poem-description" class="hint"></p> ${ buttons ( 'daily-poetry-job' , 'dailyPoetryJob' ) } ` ; $ ( '#template-fields' ). before ( box );
const values = JSON . parse ( $ ( '#template-data' ). value ); $ ( '#poem-choice' ). value = values . poem_id || '' ;
function description (){ const selected = provider . catalog . find ( p => p . id === $ ( '#poem-choice' ). value ) || provider . current ; $ ( '#poem-description' ). textContent = selected . title + ' · ' + selected . author + ' · ' + t ( 'poetryCatalog' ) + provider . catalog . length ; const raw = $ ( '#field-poem_id' ); if ( raw ) raw . parentElement . hidden = true ;}
description (); $ ( '#poem-choice' ). onchange = ()=>{ const data = JSON . parse ( $ ( '#template-data' ). value ); delete data . poem_title ; delete data . poem_author ; delete data . poem_lines ; delete data . poem_source ; delete data . poem_edition ; if ( $ ( '#poem-choice' ). value ) data . poem_id = $ ( '#poem-choice' ). value ; else { delete data . poem_id ; delete data . date ;} $ ( '#template-data' ). value = JSON . stringify ( data , null , 2 ); $ ( '#template-data' ). oninput (); description ();};
bind ( 'daily-poetry-job' ,()=>{ showModal ( `<h2> ${ t ( 'dailyPoetryJob' ) } </h2><form id="poetry-job-form"><div class="field"><label for="poetry-job-time"> ${ t ( 'time' ) } </label><input id="poetry-job-time" type="time" required value="07:00"></div><label class="check-row"><input id="poetry-job-enabled" type="checkbox"> ${ t ( 'enabled' ) } </label><p class="hint"> ${ t ( 'poetryScheduleHint' ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form>` ); $ ( '#poetry-job-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); await api ( '/api/v1/jobs' ,{ method : 'POST' , body : { device_id : state . device , name : t ( 'dailyPoetryJob' ), enabled : $ ( '#poetry-job-enabled' ). checked , schedule : { type : 'daily' , time : $ ( '#poetry-job-time' ). value }, source : { type : 'template' , template : 'daily-poetry' , data : {}, profile : $ ( '#template-profile' ). value }, display_duration : 0 , restore_previous : false , priority : 50 }}); $ ( '#modal' ). close (); await navigate ( 'automation' );});});
}
2026-10-02 14:52:57 +08:00
2026-10-02 16:20:54 +08:00
async function officialContentInfo (){
2026-10-02 14:52:57 +08:00
const info = document . createElement ( 'details' ); info . className = 'official-content-info' ;
2026-10-02 16:20:54 +08:00
const zh = state . lang === 'zh' ;
info . innerHTML = `<summary> ${ zh ? '官方小组件' : 'Official widgets' } </summary><p> ${ zh ? '正在读取…' : 'Loading…' } </p>` ;
2026-10-02 14:52:57 +08:00
$ ( '.template-grid' ). before ( info );
2026-10-02 16:20:54 +08:00
try {
const data = await api ( '/api/v1/devices/' + state . device + '/widgets' );
const label = data . source === 'live' ? ( zh ? '实时目录' : 'Live catalog' ) : data . source === 'mock' ? 'Mock' : ( zh ? '历史目录 · ' + data . observed_at : 'Catalog snapshot · ' + data . observed_at );
2026-10-04 00:20:43 +08:00
const currentLabel = data . status === 'DEVICE_REQUIRED' ? ( zh ? '官方账号已连接,请先关联画框' : 'Official account connected; link a frame first' ) :! data . configured ? ( zh ? '尚未授权官方账号,当前状态不可读取。' : 'Official account not authorized; current state is unavailable.' ) : data . status === 'NO_WIDGET' ? ( zh ? '未使用官方小组件' : 'No official widget selected' ) : data . current_verified && data . current ? (( zh ? '当前小组件:' : 'Current widget: ' ) + data . current . name ) : ( zh ? '当前小组件尚未核实' : 'Current widget is unverified' );
2026-10-08 23:18:11 +08:00
info . innerHTML = `<summary> ${ zh ? '官方小组件' : 'Official widgets' } · ${ data . catalog . length }${ ! data . configured ? ( zh ? ' · 需官方账号授权' : ' · Official account required' ) : '' } </summary><p> ${ esc ( label ) } </p><p class="notice"> ${ esc ( currentLabel ) } </p><p class="notice widget-auth-notice"> ${ zh ? '如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。' : 'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.' } </p><p> ${ zh ? '使用或取消订阅会改变画框内容;发送后不自动重试,结果在历史记录中核对。' : 'Selecting or cancelling changes frame content. Writes are never automatically retried; check History.' } </p><input type="search" aria-label=" ${ zh ? '搜索小组件' : 'Search widgets' } " placeholder=" ${ zh ? '搜索名称或分类' : 'Search name or category' } "><div class="widget-list"></div>` ;
2026-10-02 16:20:54 +08:00
const render = ()=>{ const query = info . querySelector ( 'input' ). value . toLowerCase (); info . querySelector ( '.widget-list' ). innerHTML = data . catalog . filter ( x =>( x . name + ' ' + x . tags . join ( ' ' )). toLowerCase (). includes ( query )). map ( x => `<article class="card"><strong> ${ esc ( x . name ) } </strong><p> ${ esc ( x . tags . join ( ' · ' )) } </p><small> ${ esc ( x . arg_keys . join ( ' · ' )) } </small></article>` ). join ( '' );};
2026-10-08 23:18:11 +08:00
info . querySelector ( 'input' ). oninput = render ; render (); officialConnectionPanel ( info ); await officialWidgetControls ( info , data );
2026-10-05 00:21:17 +08:00
} catch ( e ){ info . innerHTML = `<summary> ${ zh ? '官方小组件' : 'Official widgets' } </summary><p class="notice widget-auth-notice"> ${ esc ( e . message ) } </p>` ; officialConnectionPanel ( info );}
2026-10-02 14:52:57 +08:00
}
2026-10-04 00:20:43 +08:00
async function officialConnectionPanel ( info ){
const zh = state . lang === 'zh' , path = '/api/v1/devices/' + state . device + '/official/connection' ;
const panel = document . createElement ( 'section' ); panel . className = 'official-account-panel' ; info . querySelector ( '.widget-auth-notice' ). after ( panel );
const render = connection =>{
panel . innerHTML = connection . authorized ? `<h3> ${ zh ? '官方账号已连接' : 'Official account connected' } </h3><p> ${ esc ( connection . username ) } </p><p> ${ connection . robot_id ? ( zh ? '已关联画框' : 'Frame linked' ) : ( zh ? '请选择并关联你的画框' : 'Choose and link your frame' ) } </p><div class="actions"><button id="official-discover" type="button"> ${ zh ? '查询账号设备' : 'Discover devices' } </button><button id="official-disconnect" type="button"> ${ zh ? '断开连接' : 'Disconnect' } </button></div><div id="official-device-picker"></div>` : `<h3> ${ zh ? '连接 KoKonna 官方账号' : 'Connect KoKonna official account' } </h3><p> ${ zh ? '此处不是 Frame Flow 或 NAS 账号。密码不保存,登录会话会保存为 NAS 上受限的 secret 文件。连接仅查询,不切换小组件。' : 'Use your KoKonna account, not Frame Flow or NAS credentials. Passwords are not saved; the session is stored in a restricted NAS secret file. Connecting only queries data.' } </p><form id="official-login-form"><div class="field"><label for="official-username"> ${ zh ? '官方账号' : 'Official username' } </label><input id="official-username" required maxlength="120" autocomplete="username"></div><div class="field"><label for="official-password"> ${ zh ? '官方密码' : 'Official password' } </label><input id="official-password" required maxlength="256" type="password" autocomplete="off"></div><button type="submit" class="primary"> ${ zh ? '授权连接' : 'Authorize connection' } </button></form>` ;
if ( connection . authorized ){
$ ( '#official-disconnect' ). onclick = busy ( async ()=>{ render ( await api ( path ,{ method : 'DELETE' })); toast ( zh ? '已断开官方连接' : 'Official connection disconnected' );});
const picker = devices =>{ const box = $ ( '#official-device-picker' ); box . innerHTML = devices . length ? `<form id="official-select-form"><div class="field"><label for="official-device"> ${ zh ? '官方账号下的画框' : 'Frames in your official account' } </label><select id="official-device"> ${ devices . map ( d => `<option value=" ${ d . id } " ${ d . id === connection . robot_id ? 'selected' : '' } > ${ esc ( d . name ) } · ${ esc ( d . model_code || '' ) } · ${ d . online ? 'ONLINE' : 'OFFLINE' } </option>` ). join ( '' ) } </select></div><button type="submit" class="primary"> ${ zh ? '关联到当前 Frame Flow 设备' : 'Link to this Frame Flow device' } </button></form>` : `<p> ${ zh ? '账号下没有可关联设备' : 'No devices found in this account' } </p>` ; if ( devices . length ) $ ( '#official-select-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); await api ( path ,{ method : 'PUT' , body : { robot_id : Number ( $ ( '#official-device' ). value )}}); await navigate ( 'templates' );});};
$ ( '#official-discover' ). onclick = busy ( async ()=>{ picker (( await api ( path + '?discover=true' )). devices );});
if ( connection . devices . length ) picker ( connection . devices );
} else $ ( '#official-login-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); const username = $ ( '#official-username' ). value . trim (), password = $ ( '#official-password' ). value ; $ ( '#official-password' ). value = '' ; render ( await api ( path ,{ method : 'POST' , body : { username , password }}));});
};
try { render ( await api ( path ));} catch { panel . textContent = zh ? '官方账号连接由管理员管理' : 'Official account connection is managed by an administrator' ;}
}
2026-10-05 00:52:39 +08:00
2026-10-05 08:43:07 +08:00
async function photosPeoplePicker ( selected ){
const target = $ ( '#source-people-panel' ), path = '/api/v1/integrations/synology-photos' ;
const status = await api ( path );
if ( ! status . configured ){ target . innerHTML = `<p class="hint"> ${ t ( 'photosConfigure' ) } </p>` ; return ;}
if ( ! status . connected ){
2026-10-10 08:36:23 +08:00
target . innerHTML = `<div class="field"><label for="photos-username"> ${ t ( 'nasUsername' ) } </label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password"> ${ t ( 'nasPassword' ) } </label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint"> ${ t ( 'photosSessionHint' ) } </p><button type="button" id="photos-connect"> ${ t ( 'photosConnect' ) } </button>` ;
2026-10-05 08:43:07 +08:00
$ ( '#photos-connect' ). onclick = busy ( async ()=>{ const password = $ ( '#photos-password' ). value ; $ ( '#photos-password' ). value = '' ; await api ( path ,{ method : 'POST' , body : { username : $ ( '#photos-username' ). value , password }}); await photosPeoplePicker ( selected );}); return ;
}
2026-10-08 23:18:11 +08:00
const people = await api ( path + '/people?space=' + encodeURIComponent ( $ ( '#source-people-space' ) ? . value || 'shared' ));
2026-10-10 09:38:27 +08:00
target . innerHTML = `<div class="field"><label for="people-search"> ${ t ( 'peopleSearch' ) } </label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox"> ${ t ( 'peopleUnnamed' ) } </label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p>` ;
2026-10-05 08:43:07 +08:00
const render = ()=>{ const query = $ ( '#people-search' ). value . toLowerCase (); const rows = people . filter ( p =>( p . named || selected . has ( p . id ) || $ ( '#people-unnamed' ). checked ) && ( p . name . toLowerCase (). includes ( query ) || String ( p . id ). includes ( query ))); $ ( '#people-list' ). innerHTML = rows . map ( p => `<label class="check-row"><input type="checkbox" data-person=" ${ p . id } " ${ selected . has ( p . id ) ? 'checked' : '' } > ${ esc ( p . name ) } · ${ p . count } </label>` ). join ( '' ); $ ( '#people-selected' ). textContent = t ( 'peopleSelected' ) + selected . size ; $ ( '#people-list' ). querySelectorAll ( '[data-person]' ). forEach ( box => box . onchange = ()=>{ const id = Number ( box . dataset . person ); box . checked ? selected . add ( id ) : selected . delete ( id ); $ ( '#people-selected' ). textContent = t ( 'peopleSelected' ) + selected . size ;});};
2026-10-10 09:38:27 +08:00
$ ( '#people-search' ). oninput = render ; $ ( '#people-unnamed' ). onchange = render ; render ();
2026-10-05 08:43:07 +08:00
}
2026-10-10 08:36:23 +08:00
async function photosConnectionPanel (){
const status = await api ( '/api/v1/integrations/synology-photos' );
const box = $ ( '#photos-connection' ); if ( ! box ) return ;
2026-10-10 09:38:27 +08:00
box . innerHTML = `<div class="section-heading"><div><h3>Synology Photos · ${ t ( 'photosSharedConnection' ) } </h3><p class="hint"> ${ t ( 'photosConnectionHelp' ) } </p></div><span class="badge ${ status . connected ? 'ok' : 'warning' } "> ${ t ( status . connected ? 'photosConnected' : 'photosDisconnected' ) } </span></div><p class="notice ${ status . connected ? '' : 'warning' } "> ${ t ( ! status . configured ? 'photosSetupHelp' : status . connected ? 'photosConnectedHelp' : 'photosReconnectHelp' ) } </p><div class="actions"> ${ buttons ( 'photos-login' , status . connected ? 'photosReconnect' : 'photosConnect' , true ) }${ buttons ( 'photos-status-refresh' , 'refresh' ) }${ status . connected ? buttons ( 'photos-disconnect' , 'photosDisconnect' ) : '' } </div>` ;
2026-10-10 08:36:23 +08:00
$ ( '#photos-login' ). disabled =! status . configured ;
bind ( 'photos-login' ,()=> photosLoginDialog ());
bind ( 'photos-status-refresh' ,()=> photosConnectionPanel ());
2026-10-10 09:38:27 +08:00
bind ( 'photos-disconnect' ,()=> confirmDialog ( t ( 'photosSharedDisconnectWarning' ), async ()=>{ await api ( '/api/v1/integrations/synology-photos' ,{ method : 'DELETE' }); await navigate ( 'photo-sources' );}));
2026-10-10 08:36:23 +08:00
}
function photosLoginDialog (){
showModal ( `<h2> ${ t ( 'photosConnect' ) } </h2><p class="hint"> ${ t ( 'photosLoginHelp' ) } </p><form id="photos-login-form"><div class="field"><label for="photos-login-user"> ${ t ( 'nasUsername' ) } </label><input id="photos-login-user" required autocomplete="username"></div><div class="field"><label for="photos-login-password"> ${ t ( 'nasPassword' ) } </label><input id="photos-login-password" required type="password" autocomplete="current-password"></div><p class="hint"> ${ t ( 'photosSessionHint' ) } </p><p id="photos-login-error" role="alert" hidden></p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'photosConnect' ) } </button></div></form>` );
$ ( '#photos-login-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); const password = $ ( '#photos-login-password' ). value ; $ ( '#photos-login-password' ). value = '' ; try { await api ( '/api/v1/integrations/synology-photos' ,{ method : 'POST' , body : { username : $ ( '#photos-login-user' ). value , password }}); $ ( '#modal' ). close (); await photosConnectionPanel (); toast ( t ( 'photosConnectedHelp' ));} catch ( error ){ const box = $ ( '#photos-login-error' ); box . hidden = false ; box . className = 'notice warning' ; box . textContent = error . message ;}});
}
2026-10-05 00:52:39 +08:00
async function photoSourcesPanel (){
const section = document . createElement ( 'section' ); section . className = 'card photo-sources-panel' ; $ ( '#main' ). append ( section );
const sources = await api ( '/api/v1/photo-sources' ); if ( ! section . isConnected ) return ;
2026-10-10 09:38:27 +08:00
section . innerHTML = `<div class="section-heading"><div><h3> ${ t ( 'photoSources' ) } </h3><p class="hint"> ${ t ( 'sourceHint' ) } </p></div> ${ buttons ( 'add-photo-source' , 'addSource' ) } </div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list"> ${ sources . length ? sources . map ( s => `<article class="source-card"><h4> ${ esc ( s . name ) } </h4><p class="hint source-identity"> ${ t ( 'sourceIdLabel' ) } : ${ esc ( s . id ) } · ${ t ( 'sourceLocation' ) } : ${ esc ( s . location ) } </p><p class="hint"> ${ s . people_filter ? t ( 'sourceSharedPhotos' ) : s . kind === 'local' ? t ( 'sourceLocalOnly' ) : t ( 'sourceOwnCredentials' ) } </p><p class="hint"> ${ esc ( s . kind ) } · ${ t ( s . enabled ? 'enabled' : 'disabled' ) }${ s . people_filter ? ' · ' + t ( 'peopleFilter' ) : '' }${ s . orientation ? ' · ' + t ({ auto : 'orientationAuto' , landscape : 'orientationLandscape' , portrait : 'orientationPortrait' } [s.orientation]):''}</p><div class="actions"><button data-source-browse=" ${ esc ( s . id ) } "> ${ t ( 'browse' ) } </button><button data-source-random=" ${ esc ( s . id ) } " ${ s . enabled ? '' : 'disabled' } > ${ t ( 'randomPreview' ) } </button><button data-source-job=" ${ esc ( s . id ) } "> ${ t ( 'sourceJob' ) } </button><button data-source-edit=" ${ esc ( s . id ) } "> ${ t ( 'edit' ) } </button><button class="danger" data-source-delete=" ${ esc ( s . id ) } "> ${ t ( 'remove' ) } </button></div></article>` ). join ( '' ) : `<p class="hint"> ${ t ( 'noSources' ) } </p>` } </div><div id="source-browser"></div>` ;
2026-10-10 08:36:23 +08:00
await photosConnectionPanel ();
2026-10-05 00:52:39 +08:00
bind ( 'add-photo-source' ,()=> photoSourceForm ());
section . querySelectorAll ( '[data-source-edit]' ). forEach ( b => b . onclick = ()=> photoSourceForm ( sources . find ( s => s . id === b . dataset . sourceEdit )));
2026-10-05 09:09:18 +08:00
section . querySelectorAll ( '[data-source-delete]' ). forEach ( b => b . onclick = ()=> confirmDialog ( t ( 'removeConfirm' ), async ()=>{ await api ( '/api/v1/photo-sources/' + b . dataset . sourceDelete ,{ method : 'DELETE' }); await navigate ( 'photo-sources' );}));
2026-10-05 00:52:39 +08:00
section . querySelectorAll ( '[data-source-random]' ). forEach ( b => b . onclick = busy (()=> previewSource ( b . dataset . sourceRandom )));
section . querySelectorAll ( '[data-source-browse]' ). forEach ( b => b . onclick = busy (()=> browseSource ( b . dataset . sourceBrowse )));
section . querySelectorAll ( '[data-source-job]' ). forEach ( b => b . onclick = ()=> sourceJobForm ( b . dataset . sourceJob ));
}
2026-10-05 08:43:07 +08:00
async function photoSourceForm ( source = null ){
2026-10-10 09:38:27 +08:00
showModal ( `<h2> ${ t ( source ? 'edit' : 'addSource' ) } </h2><p class="notice"> ${ t ( 'sourceIndependentHelp' ) } </p><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value=" ${ esc ( source ? . id || '' ) } " ${ source ? 'readonly' : '' } ></div><div class="field"><label for="source-name"> ${ t ( 'name' ) } </label><input id="source-name" required maxlength="120" value=" ${ esc ( source ? . name || '' ) } "></div><div class="field"><label for="source-kind"> ${ t ( 'sourceKind' ) } </label><select id="source-kind"><option value="local"> ${ t ( 'nasDirectory' ) } </option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location"> ${ t ( 'sourceLocation' ) } </label><input id="source-location" required maxlength="2048" value=" ${ esc ( source ? . location || '.' ) } "><p class="hint"> ${ t ( 'sourceLocationHint' ) } </p></div><div class="field"><label for="source-credential"> ${ t ( 'sourceCredential' ) } </label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value=" ${ esc ( source ? . credential_env || '' ) } " placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint"> ${ t ( 'sourceCredentialHint' ) } </p></div><div class="field"><label for="source-orientation"> ${ t ( 'photoOrientation' ) } </label><select id="source-orientation"> ${ source &&! source . orientation ? `<option value=""> ${ t ( 'orientationLegacy' ) } </option>` : '' } <option value="auto"> ${ t ( 'orientationAuto' ) } </option><option value="landscape"> ${ t ( 'orientationLandscape' ) } </option><option value="portrait"> ${ t ( 'orientationPortrait' ) } </option></select><p class="hint"> ${ t ( 'orientationHint' ) } </p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${ source ? . organized_only ? 'checked' : '' } > ${ t ( 'organizedPhotos' ) } </label><label class="check-row"><input id="source-caption" type="checkbox" ${ source ? . folder_caption ? 'checked' : '' } > ${ t ( 'folderCaption' ) } </label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${ source ? . synology_previews ? 'checked' : '' } > ${ t ( 'synologyPreviews' ) } </label><p class="hint"> ${ t ( 'folderCaptionHint' ) } </p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${ source ? . people_filter ? 'checked' : '' } > ${ t ( 'peopleFilter' ) } </label><p class="hint"> ${ t ( 'peopleHint' ) } </p><button id="source-people-load" type="button"> ${ t ( 'choosePeople' ) } </button><div id="source-people-panel"><p class="hint"> ${ t ( 'peopleSelected' ) }${ ( source ? . person_ids || []). length } </p></div><label for="source-person-match"> ${ t ( 'personMatch' ) } </label><select id="source-person-match"><option value="any"> ${ t ( 'personAny' ) } </option><option value="all"> ${ t ( 'personAll' ) } </option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${ source ? . recursive === false ? '' : 'checked' } > ${ t ( 'sourceRecursive' ) } </label><label class="check-row"><input id="source-enabled" type="checkbox" ${ source ? . enabled === false ? '' : 'checked' } > ${ t ( 'enabled' ) } </label></div><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button type="submit" class="primary"> ${ t ( 'save' ) } </button></div></form>` );
2026-10-10 08:36:23 +08:00
$ ( '#source-orientation' ). value = source ? ( source . orientation || '' ) : 'auto' ; const selectedPeople = new Set ( source ? . person_ids || []); $ ( '#source-person-match' ). value = source ? . person_match || 'any' ; $ ( '#source-people-load' ). onclick = busy ( async ()=>{ await photosPeoplePicker ( selectedPeople );}); $ ( '#source-kind' ). value = source ? . kind || 'local' ; const update = ()=>{ const dav = $ ( '#source-kind' ). value !== 'local' ; $ ( '#source-orientation' ). disabled = dav ; $ ( '#source-credential' ). disabled =! dav ; $ ( '#source-credential' ). closest ( '.field' ). hidden = $ ( '#source-kind' ). value !== 'webdav' ; const oauth = $ ( '#source-oauth' ); if ( oauth ) oauth . closest ( '.field' ). hidden =! [ 'google-drive' , 'alipan' , 'baidu' ]. includes ( $ ( '#source-kind' ). value ); const quality = $ ( '#source-local-quality' ); if ( quality ) quality . hidden = dav ; $ ( '#source-recursive' ). disabled = $ ( '#source-kind' ). value === 'webdav' ; $ ( '#source-synology-previews' ). disabled = dav ; $ ( '#source-organized' ). disabled = dav ; $ ( '#source-caption' ). disabled = dav ; $ ( '#source-people-filter' ). disabled = dav ; $ ( '#source-people-load' ). disabled = dav ;}; addPhotoQualityFields ( source ); update (); $ ( '#source-kind' ). onchange = update ; $ ( '#source-people-space' ). onchange = ()=>{ selectedPeople . clear (); $ ( '#source-people-panel' ). innerHTML = '' ;};
2026-10-08 23:18:11 +08:00
$ ( '#photo-source-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); const oauth = $ ( '#source-oauth' ). value ; const sourceId = $ ( '#source-id' ). value ; const native = [ 'google-drive' , 'alipan' , 'baidu' ]. includes ( $ ( '#source-kind' ). value ); if ( oauth && native ) JSON . parse ( oauth ); const dav = $ ( '#source-kind' ). value !== 'local' ; await api ( '/api/v1/photo-sources/' + $ ( '#source-id' ). value ,{ method : 'PUT' , body : { name : $ ( '#source-name' ). value , kind : $ ( '#source-kind' ). value , location : $ ( '#source-location' ). value , recursive : $ ( '#source-kind' ). value !== 'webdav' && $ ( '#source-recursive' ). checked , synology_previews :! dav && $ ( '#source-synology-previews' ). checked , catalog_limit : $ ( '#source-organized' ). checked ? 100000 : ( source ? . catalog_limit || 10000 ), organized_only :! dav && $ ( '#source-organized' ). checked , folder_caption :! dav && $ ( '#source-caption' ). checked , orientation : dav ? null : ( $ ( '#source-orientation' ). value || null ), people_filter :! dav && $ ( '#source-people-filter' ). checked , person_ids : [... selectedPeople ], person_match : $ ( '#source-person-match' ). value , people_space : $ ( '#source-people-space' ). value , minimum_sharpness : Number ( $ ( '#source-sharpness' ). value ), minimum_brightness : Number ( $ ( '#source-bright-min' ). value ), maximum_brightness : Number ( $ ( '#source-bright-max' ). value ), avoid_duplicates : $ ( '#source-dedup' ). checked , enabled : $ ( '#source-enabled' ). checked , credential_env : dav ? ( $ ( '#source-credential' ). value || null ) : null }}); if ( oauth && native ){ $ ( '#source-oauth' ). value = '' ; await api ( '/api/v1/photo-sources/' + sourceId + '/credentials' ,{ method : 'PUT' , body : { key : oauth }});} $ ( '#modal' ). close (); await navigate ( 'photo-sources' );});
2026-10-05 00:52:39 +08:00
}
async function browseSource ( id , offset = 0 , refresh = false ){
2026-10-05 22:44:41 +08:00
const result = await api ( '/api/v1/photo-sources/' + id + '/images?device_id=' + encodeURIComponent ( state . device ) + '&offset=' + offset + '&limit=50&refresh=' + refresh ); const box = $ ( '#source-browser' ); if ( ! box ) return ;
2026-10-05 10:38:20 +08:00
box . innerHTML = `<h4> ${ t ( 'sourceImages' ) } · ${ result . total }${ result . total >= result . catalog_limit ? ' (limit)' : '' } </h4><p class="hint"> ${ t ( 'sourceBrowseHint' ) } </p><div class="source-items"> ${ result . items . map (( x , i )=> `<button data-source-item=" ${ i } "> ${ esc ( x . name ) }${ x . width ? `<small> ${ x . width } × ${ x . height } </small>` : '' }${ x . caption ? `<small> ${ esc ([ x . caption . title , x . caption . date , x . caption . place ]. filter ( Boolean ). join ( ' · ' )) } </small>` : '' } </button>` ). join ( '' ) || t ( 'noSources' ) } </div><div class="actions"><button id="source-prev" ${ offset ? '' : 'disabled' } > ${ t ( 'previousPage' ) } </button><button id="source-next" ${ offset + result . items . length < result . total ? '' : 'disabled' } > ${ t ( 'nextPage' ) } </button> ${ buttons ( 'source-refresh' , 'refresh' ) } </div>` ;
2026-10-05 00:52:39 +08:00
box . querySelectorAll ( '[data-source-item]' ). forEach ( b => b . onclick = busy (()=> previewSource ( id , result . items [ Number ( b . dataset . sourceItem )]. id ))); bind ( 'source-prev' ,()=> browseSource ( id , Math . max ( 0 , offset - 50 ))); bind ( 'source-next' ,()=> browseSource ( id , offset + 50 )); bind ( 'source-refresh' ,()=> browseSource ( id , 0 , true ));
}
async function previewSource ( id , item_id = null ){
const acceptedResult = await api ( '/api/v1/devices/' + state . device + '/upload' ,{ method : 'POST' , body : { type : 'photo_source' , source_id : id , item_id , fit : 'contain' , profile : 'color' }, headers : { 'Idempotency-Key' : clientUUID ()}}); const op = await waitOperation ( acceptedResult . operation_id );
showModal ( `<h2> ${ t ( 'preview' ) } </h2><img id="source-preview" class="preview" alt=" ${ t ( 'preview' ) } "><p class="hint"> ${ t ( 'sourcePreviewHint' ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) }${ buttons ( 'source-display' , 'display' , true ) } </div>` ); await blobImage ( $ ( '#source-preview' ), op . result . url ); $ ( '#source-display' ). disabled =! liveWriteAllowed (); bind ( 'source-display' ,()=> displayDialog ({ type : 'image' , asset_id : op . result . asset_id }));
}
function sourceJobForm ( id ){
2026-10-06 09:31:09 +08:00
showModal ( `<h2> ${ t ( 'sourceJob' ) } </h2><form id="source-job-form"><div class="field"><label for="source-job-name"> ${ t ( 'name' ) } </label><input id="source-job-name" required maxlength="120" value=" ${ t ( 'randomPhoto' ) } "></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint"> ${ t ( 'sourceJobHint' ) } </p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox"> ${ t ( 'enabled' ) } </label></div><p class="hint"> ${ t ( 'cloudKeepLatest' ) } </p><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } <button class="primary" type="submit"> ${ t ( 'save' ) } </button></div></form>` ); $ ( '#source-job-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); await api ( '/api/v1/jobs' ,{ method : 'POST' , body : { name : $ ( '#source-job-name' ). value , device_id : state . device , enabled : $ ( '#source-job-enabled' ). checked , cleanup_unstarred : false , schedule : { type : 'cron' , cron : $ ( '#source-job-cron' ). value }, source : { type : 'photo_source' , source_id : id , fit : 'contain' , profile : 'color' }}}); $ ( '#modal' ). close (); await navigate ( 'automation' );});
2026-10-05 00:52:39 +08:00
}