'use strict'; const tr=(zh,en)=>state.lang==='zh'?zh:en; async function layoutStudioPage(){ const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null; $('#main').innerHTML=`
${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}

${tr('选择照片','Choose photos')}

${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}

${assets.map(a=>``).join('')}

${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}

${t('preview')}

${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}
`; let rendered=null; const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;}; $('#studio-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();}; $('#studio-files').onchange=busy(async()=>{const files=[...$('#studio-files').files];if(!files.length)return;const body=new FormData();files.forEach(f=>body.append('files',f));const uploaded=await api('/api/v1/layout/images',{method:'POST',body});for(const a of uploaded){const label=document.createElement('label');label.className='check-row';label.innerHTML=`${esc(a.name)}`;$('#studio-assets').append(label);}$('#studio-assets').onchange();toast(t('done'));}); async function showPlan(){ const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=plan.width+'/'+plan.height; $('#studio-region-editor').innerHTML=plan.regions.map((r,i)=>`
${['x','y','width','height'].map(k=>``).join('')}
`).join(''); const update=(el,r)=>{el.style.left=(r.x/plan.width*100)+'%';el.style.top=(r.y/plan.height*100)+'%';el.style.width=(r.width/plan.width*100)+'%';el.style.height=(r.height/plan.height*100)+'%';}; for(const [i,r] of plan.regions.entries()){ const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;update(img,r);box.append(img);await blobImage(img,'/api/v1/assets/'+r.asset_id); img.onpointerdown=e=>{e.preventDefault();img.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};img.onpointermove=ev=>{r.x=Math.max(0,Math.min(plan.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*plan.width/box.clientWidth)));r.y=Math.max(150,Math.min(plan.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*plan.height/box.clientHeight)));update(img,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};img.onpointerup=()=>{img.onpointermove=null;};}; } document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{const r=plan.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();}); } for(const id of ['studio-title','studio-message','studio-style','studio-orientation','studio-ai','studio-prompt','studio-profile'])$('#'+id).oninput=()=>{plan=null;$('#studio-render').disabled=true;invalidate();}; bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();await showPlan();$('#studio-render').disabled=false;}); bind('studio-render',async()=>{if(!plan)return;const accepted=await api('/api/v1/render',{method:'POST',body:{type:'template',template:$('#studio-style').value==='poster'?'poster':'collage',data:{layout:plan},profile:$('#studio-profile').value}});const op=await waitOperation(accepted.operation_id);rendered=op.result;$('#studio-preview').className='';$('#studio-preview').innerHTML='Preview';await blobImage($('#studio-result'),rendered.url);$('#studio-display').disabled=!liveWriteAllowed();$('#studio-download').disabled=false;}); bind('studio-display',()=>displayDialog({type:'image',asset_id:rendered.asset_id}));bind('studio-download',()=>downloadImage(rendered.url,'frame-flow-layout.jpg')); } async function connectionsPage(){ $('#main').innerHTML=`
${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}
`; await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel(); } async function aiSettingsPanel(){ const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}

${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}

${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}

`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));}); } async function storageSettingsPanel(){ const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('本地图库保留策略','Local storage retention')}

${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}

${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}

`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';}); } async function clientsPanel(){ const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('外部客户端权限','External client permissions')}

${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}

${clients.map(c=>`

${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?``:''}

`).join('')}
${scopes.map(s=>``).join('')}
`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;}); } async function webhooksPanel(){ const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`

Webhook

${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}

${hooks.map(h=>`

${esc(h.callback_url)}

`).join('')}

`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;}); } async function externalProvidersPanel(){ const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}

${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}

${providers.map(p=>`

${esc(p.name)} · ${esc(p.kind)}

`).join('')}
`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
}
async function officialWidgetControls(info,data){
 const panel=document.createElement('section');panel.className='card';panel.innerHTML=`

${tr('订阅管理','Subscription management')}

${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}

`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled; const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>`
`).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields; const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));} } function addPhotoQualityFields(source){ const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; } async function aiImagePanel(){ const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('AI 生图','AI image generation')}

${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}

${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}

`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='Generated image';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));}); } async function mqttPanel(){ const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`

MQTT · ${t(status.connected?'connected':'disconnected')}

${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}

`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));}); }