'use strict';
const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){state.galleryQuery='';state.galleryOrder='newest';state.galleryJobsRequest=null;state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){
const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]);
const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];});
state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device;
if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);});
}
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));}
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
const padTime=(hour,minute)=>`${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`;
function normaliseSchedule(schedule){const copy={...schedule};if(copy.type==='interval')return {type:copy.interval_minutes%60===0?'hours':'minutes',every:copy.interval_minutes%60===0?copy.interval_minutes/60:copy.interval_minutes};if(copy.type!=='cron')return copy;const match=/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(\*|1-5|6,0|0,6)$/.exec(copy.cron||'');if(!match)return copy;const type=match[3]==='*'?'daily':match[3]==='1-5'?'weekdays':'weekends';return {type,time:padTime(match[2],match[1])};}
function scheduleTypeLabel(type){if(type==='hours')return state.lang==='zh'?'每隔若干小时':'Every N hours';if(type==='minutes')return state.lang==='zh'?'每隔若干分钟':'Every N minutes';if(type!=='cron')return t(type);return state.lang==='zh'?'高级规则(Cron)':'Advanced rule (Cron)';}
function scheduleSummary(schedule){const value=normaliseSchedule(schedule);if(['hours','minutes'].includes(value.type))return state.lang==='zh'?`每 ${value.every} ${value.type==='hours'?'小时':'分钟'}`:`Every ${value.every} ${value.type}`;if(['daily','weekdays','weekends'].includes(value.type))return `${scheduleTypeLabel(value.type)} · ${value.time}(${state.lang==='zh'?'上海时间':'Shanghai time'})`;if(value.type==='cron')return `${scheduleTypeLabel('cron')} · ${value.cron}`;return `${scheduleTypeLabel(value.type)} · ${fmt(value.at)}`;}
function scheduleHint(type){if(['hours','minutes'].includes(type))return state.lang==='zh'?'从保存起等待一个间隔后首次执行,此后按间隔重复;不是图片显示时长。':'First runs one interval after saving, then repeats. This is separate from display duration.';if(type==='cron')return state.lang==='zh'?'仅在需要特殊规则时使用;例如“每天 08:00”请选择“每天”。':'Use only for special rules; choose Daily for a regular time.';if(['date','temporary'].includes(type))return state.lang==='zh'?'选择一次性执行的日期和时间。':'Choose the date and time for this one-time task.';return state.lang==='zh'?'选择执行时间(上海时区)。':'Choose a time in Shanghai time.';}
const buttons=(id,text,primary=false)=>``;
let toastTimer;
function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);}
async function api(path,options={}){
const headers={...(state.csrf?{'X-CSRF-Token':state.csrf}:{}),...options.headers};
if(options.body&&!(options.body instanceof FormData)){headers['Content-Type']='application/json';options.body=JSON.stringify(options.body);}
const response=await fetch(path,{...options,headers});
if(!response.ok){let error;try{error=(await response.json()).error;}catch{}if(response.status===401&&error?.code!=='OFFICIAL_AUTH_REQUIRED')forgetLogin();
const e=new Error((error?.code||response.status)+': '+operationErrorMessage(error));e.code=error?.code;throw e;}
if(response.status===204)return null;
return options.binary?await response.blob():await response.json();
}
function bind(id,fn){const element=$('#'+id);if(element)element.onclick=async event=>{try{element.disabled=true;await fn(event);}catch(e){toast(e.message);}finally{element.disabled=false;}};}
function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.message);}};}
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('');revealCurrentNavigation();}
// A destination appears only when its connection and read/write adapter are available.
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
function galleryDestinations(){
const configured=state.settings?.gallery_destinations;
const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}];
return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter));
}
function galleryDestinationName(destination){return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
function imageLoading(img){
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
const box=document.createElement('div');box.className='image-loading';
if(img.classList.contains('thumb'))box.classList.add('image-loading-thumb');
if(img.closest('.frame-demo, .image-viewer-viewport, .studio-region'))box.classList.add('image-loading-fill');
box.setAttribute('aria-busy','true');img.before(box);box.append(img);
const status=document.createElement('span');status.className='image-loading-status';status.setAttribute('role','status');status.setAttribute('aria-label',t('loading'));
const spinner=document.createElement('span');spinner.className='spinner';spinner.setAttribute('aria-hidden','true');status.append(spinner);box.append(status);return box;
}
async function blobImage(img,path){
const box=imageLoading(img);
try{
const blob=await api(path,{binary:true});if(!img.isConnected)return;
const url=URL.createObjectURL(blob);state.blobs.push(url);
// Keep the placeholder until the browser has decoded the image as well as fetched it.
img.src=url;await img.decode();if(!img.isConnected)return;box.replaceWith(img);
}catch{
box.setAttribute('aria-busy','false');box.classList.add('image-load-error');
const status=box.querySelector('.image-loading-status');status.removeAttribute('aria-label');status.textContent=t('imageLoadError');
}
}
function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>{imageLoading(img);observer.observe(img);});}
function notice(){return state.settings?.mock?`
${t('mock')}
`:'';}
async function navigate(page=state.page){document.body.classList.toggle('login-mode',!state.token);const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch;
if(!state.token){login();return;}
if(previousPage!==state.page)$('#main').replaceChildren();
const previousBlobs=state.blobs;state.blobs=[];
try{await views[state.page](epoch);for(const url of previousBlobs)URL.revokeObjectURL(url);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`
${t('apiError')}
${esc(e.message)}
${buttons('retry','refresh')}
`;bind('retry',()=>navigate());}
}
function login(){document.body.classList.add('login-mode');$('#page-title').textContent=t('signIn');$('#main').innerHTML=`FRAME FLOW
${t('connect')}
${t('tokenHint')}
`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});}
// LAN HTTP is not a secure context; getRandomValues remains available there.
function clientUUID(){if(typeof crypto.randomUUID==='function')return crypto.randomUUID();const bytes=crypto.getRandomValues(new Uint8Array(16));bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;const hex=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;}
async function accepted(path,payload){const key='web-'+clientUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;}
async function waitOperation(id){for(let n=0;n<120;n++){const operation=await api('/api/v1/operations/'+id);if(operation.status==='COMPLETED')return operation;if(['FAILED','UNKNOWN'].includes(operation.status))throw new Error(operation.status==='UNKNOWN'?t('unknown'):operationErrorMessage(operation.error));await new Promise(resolve=>setTimeout(resolve,2000));}throw new Error(t('syncing'));}
function showModal(html){
const dialog=$('#modal'),content=$('#modal-content');dialog.style.removeProperty('width');content.innerHTML=html;
const image=content.querySelector('img');dialog.classList.toggle('image-viewer',!!image);document.body.classList.toggle('image-viewer-open',!!image);dialog.onclose=()=>document.body.classList.remove('image-viewer-open');
if(image){
const viewport=document.createElement('div');viewport.className='image-viewer-viewport';image.before(viewport);viewport.append(image);
const fit=document.createElement('button'),original=document.createElement('button');fit.textContent=t('fitScreen');original.textContent=t('actualSize');
fit.type=original.type='button';fit.setAttribute('aria-pressed','true');original.setAttribute('aria-pressed','false');
const footer=document.createElement('div');footer.className='image-viewer-footer';
const title=content.querySelector('h2');if(title){title.classList.add('image-viewer-name');footer.append(title);}
const actions=content.querySelector('.modal-actions')||document.createElement('div');actions.classList.add('modal-actions','image-viewer-actions');
const cancel=actions.querySelector('#modal-cancel');if(cancel){cancel.before(fit,original);actions.append(cancel);}else{actions.append(fit,original);}
footer.append(actions);content.append(footer);
const sizeToImage=()=>{
if(!dialog.open||!image.naturalWidth)return;
const style=getComputedStyle(dialog),chrome=parseFloat(style.paddingLeft)+parseFloat(style.paddingRight)+parseFloat(style.borderLeftWidth)+parseFloat(style.borderRightWidth);
const scale=viewport.classList.contains('actual-size')?1:Math.min(1,viewport.clientHeight/image.naturalHeight);
dialog.style.width=Math.min(innerWidth-(innerWidth<=600?16:24),Math.max(360,image.naturalWidth*scale+chrome))+'px';
};
image.addEventListener('load',sizeToImage);
window.addEventListener('resize',sizeToImage);
dialog.onclose=()=>{window.removeEventListener('resize',sizeToImage);dialog.style.removeProperty('width');document.body.classList.remove('image-viewer-open');};
const setSize=actual=>{viewport.classList.toggle('actual-size',actual);fit.setAttribute('aria-pressed',String(!actual));original.setAttribute('aria-pressed',String(actual));sizeToImage();};
fit.onclick=()=>setSize(false);original.onclick=()=>setSize(true);
}
dialog.showModal();bind('modal-cancel',()=>dialog.close());
}
async function openImageViewer(path,name){showModal(`
${esc(name)}
${buttons('modal-cancel','cancel')}
`);await blobImage($('#original-image'),path);}
async function confirmDialog(message,fn){showModal(`
`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
};
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`
${t('playlists')}
`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`PIGWay Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{});
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
async function jobEditor(job){
const base={name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};
const original=job||base;
const j={...base,...original,schedule:normaliseSchedule(original.schedule||base.schedule)};
const scheduleValue=j.schedule.every||j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00');
showModal(`