Files
pigway-frame-flow/pigway_frame_flow/web/features.js
T

220 lines
61 KiB
JavaScript
Raw Normal View History

'use strict';
// Page features share the same authenticated API and display operation pipeline.
async function currentPanel(container){
const epoch=state.epoch;
const content=await api('/api/v1/devices/'+state.device+'/current-content');
if(epoch!==state.epoch||!container.isConnected)return content;
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=`<button id="current-frame-preview" class="current-frame-preview" aria-label="${t('original')} · ${t('currentContent')} #${esc(current.image_id)}"><img id="current-frame-image" alt="${t('currentContent')}"></button><span class="frame-label">${t('currentContent')} #${esc(current.image_id)}</span>`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),original.pathname+original.search);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
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;
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);
download.onclick=busy(async()=>{if(!state.templatePreview){toast(t('previewHint'));return;}await downloadImage('/api/v1/assets/'+state.templatePreview,'frame-flow-template.jpg');});
await poetryControls();
officialContentInfo();
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"><p class="hint">${t('temporaryOptionsHint')}</p><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');});
}
function enhanceGallery(){
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid gallery-filters';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('sortOrder')}</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);
if(state.tab==='local'){controls.classList.add('gallery-filters-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-invert">${t('invertSelection')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-exclude">${t('excludePhoto')}</button><button id="asset-batch-include">${t('includePhoto')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp')+' '+t('excludePhotoHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=controls.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#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();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','exclude','include','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();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;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));};
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));star.disabled=Boolean(item.excluded);star.title=item.excluded?t('clearExclusionFirst'):'';card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));exclude.disabled=Boolean(item.starred);exclude.title=item.starred?t('clearStarFirst'):'';card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.starred){const kept=document.createElement('small');kept.className='photo-mark';kept.textContent=t('photoStarred');card.querySelector('h3').after(kept);}if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}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(state.tab==='local'?'downloadShort':'download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local')card.querySelector('.actions').append(card.querySelector('[data-delete]'));});
if(state.tab==='local'){
const updateMarks=()=>{const selected=[...grid.querySelectorAll('.asset-select:checked')].map(box=>state.gallery.find(item=>item.asset_id===box.dataset.asset));const starred=selected.some(item=>item?.starred),excluded=selected.some(item=>item?.excluded);$('#asset-batch-star').disabled=!selected.length||excluded;$('#asset-batch-star').title=!selected.length?t('selectImagesFirst'):excluded?t('clearExclusionFirst'):'';$('#asset-batch-exclude').disabled=!selected.length||starred;$('#asset-batch-exclude').title=!selected.length?t('selectImagesFirst'):starred?t('clearStarFirst'):'';disabledMarkHints(grid.parentElement);};
grid.querySelectorAll('.asset-select').forEach(box=>box.onchange=updateMarks);
for(const id of ['asset-all','asset-invert']){const previous=$('#'+id).onclick;$('#'+id).onclick=event=>{previous(event);updateMarks();};}updateMarks();
}
disabledMarkHints(grid);
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
}
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">${t('weatherSettings')}</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 weather-coordinates">${['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(){
historyControls();
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')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)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);const op=state.historyPageData?.items.find(x=>x.operation_id===operationId);if(op&&['COMPLETED','FAILED','UNKNOWN'].includes(op.status)){const remove=document.createElement('button');remove.textContent=t('delete');remove.className='danger';remove.onclick=()=>deleteHistory([operationId]);actions.append(remove);}button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,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?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
async function enhanceAutomation(){
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);});}});
}
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);
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;
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>`;
$('#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();}
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();}
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();};}
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();};}
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();});}
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');});});
}
async function officialContentInfo(){
const info=document.createElement('details');info.className='official-content-info';
const zh=state.lang==='zh';
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'}</summary><p>${zh?'正在读取…':'Loading…'}</p>`;
$('.template-grid').before(info);
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);
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');
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>`;
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('');};
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);await officialWidgetControls(info,data);
}catch(e){info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'}</summary><p class="notice widget-auth-notice">${esc(e.message)}</p>`;officialConnectionPanel(info);}
}
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';}
}
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){
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>`;
$('#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;
}
const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared'));
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>`;
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;});};
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;render();
}
async function photosConnectionPanel(){
const status=await api('/api/v1/integrations/synology-photos');
const box=$('#photos-connection');if(!box)return;
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>`;
$('#photos-login').disabled=!status.configured;
bind('photos-login',()=>photosLoginDialog());
bind('photos-status-refresh',()=>photosConnectionPanel());
bind('photos-disconnect',()=>confirmDialog(t('photosSharedDisconnectWarning'),async()=>{await api('/api/v1/integrations/synology-photos',{method:'DELETE'});await navigate('photo-sources');}));
}
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;}});
}
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;
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.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind==='synology-photos'?'Synology Photos':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>`;
await photosConnectionPanel();
bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
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');}));
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));
}
async function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><input id="source-id" type="hidden" value="${esc(source?.id||'photos-'+clientUUID())}"><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" ${!source||source.kind==='synology-photos'?'hidden':''}><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="synology-photos">${t('synologyAPISource')}</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" id="source-nas-root-field"><label for="source-nas-root">${tr('NAS 原照片根目录(仅用于显示路径)','NAS original root (display only)')}</label><input id="source-nas-root" pattern="/.*" maxlength="2048" placeholder="/volume1/photo" value="${esc(source?.nas_root_path||'')}"><p class="hint">${tr('共享空间通常为 /volume1/photo,请填写实际 NAS 根目录;留空时显示 Photos 空间内路径。','Enter the actual NAS root; shared space is commonly /volume1/photo. Blank shows the path within Photos.')}</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||source.folder_caption?'checked':''}>${t('folderCaption')}</label><label hidden><input id="source-synology-previews" type="checkbox" disabled></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>`);
$('#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||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-nas-root-field').hidden=!photosAPI;$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#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=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#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=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,nas_root_path:$('#source-nas-root').value||null,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,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_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').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');});
}
async function browseSource(id,offset=0,refresh=false){
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;
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')} ${t('excludePhotoHelp')}</p><div class="source-items">${result.items.map((x,i)=>`<article class="source-photo"><button data-source-item="${i}">${esc(x.name)}${x.width?`<small>${x.width} × ${x.height}</small>`:''}${x.caption?`<small>${esc([x.caption.date,x.caption.people,x.caption.title,x.caption.place,x.caption.filename].filter(Boolean).join(' · '))}</small>`:''}</button><div class="actions"><button data-source-star="${i}" aria-pressed="${Boolean(x.starred)}" ${x.excluded?'disabled':''} title="${x.excluded?t('clearExclusionFirst'):''}">${t(x.starred?'unstar':'star')}</button><button data-source-exclude="${i}" aria-pressed="${Boolean(x.excluded)}" ${x.starred?'disabled':''} title="${x.starred?t('clearStarFirst'):''}">${t(x.excluded?'includePhoto':'excludePhoto')}</button></div>${x.starred?`<small class="photo-mark">${t('photoStarred')}</small>`:''}${x.excluded?`<small class="excluded-photo">${t('photoExcluded')}</small>`:''}</article>`).join('')||t('noSourcePhotos')}</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>`;
for(const key of ['star','exclude'])box.querySelectorAll('[data-source-'+key+']').forEach(button=>button.onclick=busy(async()=>{const item=result.items[Number(button.dataset[key==='star'?'sourceStar':'sourceExclude'])],field=key==='star'?'starred':'excluded';await api('/api/v1/photo-sources/'+id+'/preferences?device_id='+encodeURIComponent(state.device),{method:'PUT',body:{item_id:item.id,[field]:!item[field]}});await browseSource(id,offset);}));
disabledMarkHints(box);
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}));
}
async function sourceJobForm(id){
await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50});
}
function historyQuery(){return new URLSearchParams({...state.historyFilters,page:state.historyPage||1,page_size:state.historyPageSize||20});}
function historyControls(){
const data=state.historyPageData;if(!data)return;
const filters=state.historyFilters||{};
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['library-generate','display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
const wrap=$('#main .table-wrap');wrap.before(form);
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
$('#history-end').oninput=()=>$('#history-end').setCustomValidity('');$('#history-start').oninput=()=>$('#history-end').setCustomValidity('');
bind('history-reset',async()=>{state.historyFilters={};state.historyPage=1;await navigate();});
const bar=document.createElement('div');bar.className='actions history-toolbar';bar.innerHTML=`<button id="history-all">${t('selectAll')}</button><button id="history-invert">${t('invertSelection')}</button><button id="history-delete" class="danger" disabled>${t('deleteSelected')}</button><span id="history-selected" class="hint" role="status"></span>`;wrap.before(bar);
const pager=document.createElement('div');pager.className='actions history-pagination';const pages=Math.max(1,Math.ceil(data.total/data.page_size));pager.innerHTML=`<span>${t('historyTotal').replace('{count}',data.total)} · ${data.page} / ${pages}</span><button id="history-prev" ${data.page<=1?'disabled':''}>${t('previousPage')}</button><button id="history-next" ${data.page>=pages?'disabled':''}>${t('nextPage')}</button><label for="history-page-size">${t('pageSize')}</label><select id="history-page-size">${[20,50,100].map(n=>`<option value="${n}" ${n===data.page_size?'selected':''}>${n}</option>`).join('')}</select>`;wrap.after(pager);
bind('history-prev',async()=>{state.historyPage=data.page-1;await navigate();});bind('history-next',async()=>{state.historyPage=data.page+1;await navigate();});$('#history-page-size').onchange=busy(async()=>{state.historyPageSize=Number($('#history-page-size').value);state.historyPage=1;await navigate();});
const checkboxes=()=>[...document.querySelectorAll('.history-select:not(:disabled)')];
const update=()=>{const selected=checkboxes().filter(x=>x.checked);$('#history-delete').disabled=!selected.length;$('#history-selected').textContent=t('historySelected').replace('{count}',selected.length);};
for(const op of data.items){const row=[...wrap.querySelectorAll('tbody tr')].find(r=>r.dataset.operationId===op.operation_id);if(!row)continue;const box=document.createElement('input');box.type='checkbox';box.className='history-select';box.value=op.operation_id;box.setAttribute('aria-label',t('selectHistory')+' '+op.operation_id);box.disabled=!['COMPLETED','FAILED','UNKNOWN'].includes(op.status);box.onchange=update;row.cells[0].prepend(box);}
bind('history-all',()=>{const boxes=checkboxes(),checked=boxes.length>0&&boxes.every(x=>x.checked);boxes.forEach(x=>x.checked=!checked);update();});bind('history-invert',()=>{checkboxes().forEach(x=>x.checked=!x.checked);update();});bind('history-delete',()=>deleteHistory(checkboxes().filter(x=>x.checked).map(x=>x.value)));update();
}
async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').replace('{count}',ids.length),async()=>{await api('/api/v1/operation-history',{method:'DELETE',body:{operation_ids:ids}});await navigate();});}
function hideDisabledMarkTooltip(){const tooltip=document.getElementById('disabled-mark-tooltip');if(tooltip)tooltip.hidden=true;}
function disabledMarkHints(root){
hideDisabledMarkTooltip();
for(const button of root.querySelectorAll('.image-card .actions button[aria-pressed],#asset-batch-star,#asset-batch-exclude,[data-source-star],[data-source-exclude]')){
let wrapper=button.parentElement.classList.contains('disabled-mark-hint')?button.parentElement:null;
const reason=button.disabled?(button.title||wrapper?.dataset.reason||''):'';
if(!reason){if(wrapper)wrapper.replaceWith(button);continue;}
if(!wrapper){wrapper=document.createElement('span');wrapper.className='disabled-mark-hint';button.before(wrapper);wrapper.append(button);}
button.removeAttribute('title');wrapper.dataset.reason=reason;wrapper.tabIndex=0;wrapper.setAttribute('role','group');wrapper.setAttribute('aria-label',button.textContent+':'+reason);
wrapper.onmouseenter=wrapper.onfocus=()=>{
let tooltip=document.getElementById('disabled-mark-tooltip');if(!tooltip){tooltip=document.createElement('div');tooltip.id='disabled-mark-tooltip';tooltip.setAttribute('role','tooltip');document.body.append(tooltip);}
tooltip.textContent=wrapper.dataset.reason;tooltip.hidden=false;const anchor=wrapper.getBoundingClientRect(),rect=tooltip.getBoundingClientRect();tooltip.style.left=Math.max(12,Math.min(anchor.left,innerWidth-rect.width-12))+'px';tooltip.style.top=Math.max(12,Math.min(anchor.top>=rect.height+20?anchor.top-rect.height-8:anchor.bottom+8,innerHeight-rect.height-12))+'px';
};
wrapper.onmouseleave=wrapper.onblur=hideDisabledMarkTooltip;
wrapper.onkeydown=event=>{if(event.key==='Escape')hideDisabledMarkTooltip();};
}
}
window.addEventListener('resize',hideDisabledMarkTooltip);