Files
batchtube/backend/web/app.js

438 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const API='';
let state={currentView:'jobs',selectedJobId:null,inputType:'url',selectedArtifacts:new Set(['title','stats','transcript']),outputFolder:'',browserPath:'',jobs:[],sseSource:null,pollTimer:null,settings:{},apiKey:'',currentVideoId:null};
const AM={title:{icon:'📝',label:'Title'},stats:{icon:'📊',label:'Stats'},transcript:{icon:'📄',label:'Transcript'},summary:{icon:'🤖',label:'Summary'},thumbnail:{icon:'🖼',label:'Thumbnail'},mp3:{icon:'🎵',label:'MP3'},mp4:{icon:'🎬',label:'MP4'}};
// ── API ──────────────────────────────────────────────────────────
async function apiFetch(path,opts={}){
const res=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){const e=await res.json().catch(()=>({detail:res.statusText}));throw new Error(e.detail||res.statusText);}
return res.json();
}
// ── View Management ───────────────────────────────────────────────
function showView(name){
['jobs','new','settings'].forEach(v=>{const el=document.getElementById('view-'+v);if(el)el.classList.toggle('hidden',v!==name);});
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
const nb=document.getElementById('nav-'+name);if(nb)nb.classList.add('active');
state.currentView=name;
if(name==='settings')renderSettings();
if(name==='jobs'&&state.selectedJobId)loadJobDetail(state.selectedJobId);
}
// ── Artifact Grid ─────────────────────────────────────────────────
function renderArtifactGrid(){
const grid=document.getElementById('artifact-grid');if(!grid)return;
grid.innerHTML=Object.entries(AM).map(([k,m])=>
'<label class="artifact-check '+(state.selectedArtifacts.has(k)?'checked':'')+'" onclick="toggleArtifact(\''+k+'\',this)">'+
'<span class="artifact-icon">'+m.icon+'</span><span class="artifact-label">'+m.label+'</span></label>'
).join('');
}
function toggleArtifact(key,el){
if(state.selectedArtifacts.has(key)){state.selectedArtifacts.delete(key);el.classList.remove('checked');}
else{state.selectedArtifacts.add(key);el.classList.add('checked');}
document.getElementById('resolution-group').classList.toggle('hidden',!state.selectedArtifacts.has('mp4'));
}
function selectInputType(type){
state.inputType=type;
document.querySelectorAll('.type-pill').forEach(p=>p.classList.toggle('selected',p.dataset.type===type));
const isCsv=type==='csv';
document.getElementById('input-url-group').classList.toggle('hidden',isCsv);
document.getElementById('input-csv-group').classList.toggle('hidden',!isCsv);
const labels={url:'YouTube URL',playlist:'Playlist URL',handle:'Channel Handle (@username)'};
const phs={url:'https://www.youtube.com/watch?v=...',playlist:'https://www.youtube.com/playlist?list=...',handle:'@ChannelName'};
const lbl=document.getElementById('input-label');const inp=document.getElementById('input-value');
if(lbl)lbl.textContent=labels[type]||type;if(inp)inp.placeholder=phs[type]||'';
}
// ── Create Job ────────────────────────────────────────────────────
async function createJob(){
const inputVal=(state.inputType==='csv'
?document.getElementById('input-csv-value').value
:document.getElementById('input-value').value).trim();
if(!inputVal){toast('Please enter a YouTube URL/handle/playlist.','error');return;}
if(!state.outputFolder){toast('Please choose an output folder.','error');return;}
if(state.selectedArtifacts.size===0){toast('Select at least one artifact to extract.','error');return;}
const btn=document.getElementById('create-btn');
btn.disabled=true;btn.innerHTML='<span class="spinner"></span> Starting…';
try{
const body={
input_type:state.inputType,
input_value:inputVal,
output_folder:state.outputFolder,
selections:Array.from(state.selectedArtifacts),
resolution:document.getElementById('resolution').value,
delay_min:parseFloat(document.getElementById('delay-min').value)||2,
delay_max:parseFloat(document.getElementById('delay-max').value)||7,
openrouter_api_key:document.getElementById('api-key').value.trim(),
openrouter_model:document.getElementById('or-model').value.trim()||'openai/gpt-4o-mini',
summary_prompt:document.getElementById('summary-prompt').value.trim(),
};
state.apiKey=body.openrouter_api_key;
const res=await apiFetch('/api/jobs',{method:'POST',body:JSON.stringify(body)});
toast('Job started! Extracting '+body.selections.join(', '),'success');
state.selectedJobId=res.job_id;
showView('jobs');
await loadJobDetail(res.job_id);
subscribeSSE(res.job_id);
refreshSidebar();
}catch(e){toast('Error: '+e.message,'error');}
finally{btn.disabled=false;btn.innerHTML='▲ Start Extraction';}
}
// ── Job Controls ──────────────────────────────────────────────────
async function pauseJob(id){try{await apiFetch('/api/jobs/'+id+'/pause',{method:'POST'});toast('Job paused','info');refreshJobDetail(id);}catch(e){toast(e.message,'error');}}
async function resumeJob(id){
const key=state.apiKey||prompt('Enter OpenRouter API key (needed for summaries, or leave blank):','');
try{await apiFetch('/api/jobs/'+id+'/resume',{method:'POST',body:JSON.stringify({openrouter_api_key:key||''})});
toast('Job resumed','info');subscribeSSE(id);refreshJobDetail(id);}catch(e){toast(e.message,'error');}
}
async function stopJob(id){if(!confirm('Stop this job?'))return;try{await apiFetch('/api/jobs/'+id+'/stop',{method:'POST'});toast('Job stopped','info');refreshJobDetail(id);}catch(e){toast(e.message,'error');}}
async function skipVideo(id){try{await apiFetch('/api/jobs/'+id+'/skip',{method:'POST'});toast('Skip signal sent','info');}catch(e){toast(e.message,'error');}}
// ── Folder Browser ────────────────────────────────────────────────
function openFolderBrowser(){
document.getElementById('folder-modal').classList.remove('hidden');
browseTo(state.browserPath||'');
}
function closeFolderModal(){document.getElementById('folder-modal').classList.add('hidden');}
async function browseTo(path){
try{
const data=await apiFetch('/api/browse?path='+encodeURIComponent(path));
state.browserPath=data.current;
document.getElementById('folder-current-path').textContent=data.current;
document.getElementById('folder-modal-path').value=data.current;
const list=document.getElementById('folder-browser-list');
let html='';
if(data.parent) html+='<div class="folder-entry" onclick="browseTo(\''+escJ(data.parent)+'\')"><span class="entry-icon">⬆</span>.. (parent)</div>';
(data.entries||[]).forEach(e=>{
if(e.is_dir) html+='<div class="folder-entry" onclick="browseTo(\''+escJ(e.path)+'\')"><span class="entry-icon">📁</span>'+escH(e.name)+'</div>';
});
list.innerHTML=html||'<div style="padding:12px;color:var(--text-muted)">(empty folder)</div>';
}catch(err){toast('Browse error: '+err.message,'error');}
}
function navigateToTyped(){browseTo(document.getElementById('folder-modal-path').value.trim());}
async function createNewFolder(){
const name=prompt('New folder name:','youtube-downloads');if(!name)return;
const newPath=state.browserPath.replace(/\\/g,'/')+'/'+name;
try{await apiFetch('/api/mkdir',{method:'POST',body:JSON.stringify({path:newPath})});browseTo(newPath);}
catch(e){toast('Could not create folder: '+e.message,'error');}
}
function selectCurrentFolder(){
state.outputFolder=state.browserPath;
document.getElementById('output-folder').value=state.browserPath;
document.getElementById('folder-path-text').textContent=state.browserPath;
closeFolderModal();
toast('Folder selected: '+state.browserPath,'success');
}
function escJ(s){return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'");}
function escH(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// ── SSE Subscription ──────────────────────────────────────────────
function subscribeSSE(jobId){
if(state.sseSource){state.sseSource.close();state.sseSource=null;}
const es=new EventSource(API+'/api/jobs/'+jobId+'/events');
state.sseSource=es;
const handle=(type,fn)=>es.addEventListener(type,e=>{try{fn(JSON.parse(e.data));}catch(ex){}});
handle('job_status',d=>{refreshJobDetail(d.job_id);refreshSidebar();});
handle('video_list',d=>{renderVideoTable(jobId,d.videos||[]);});
handle('video_start',d=>{state.currentVideoId=d.video_id;highlightVideoRow(d.video_id);});
handle('video_title',d=>{updateVideoTitle(d.video_id,d.title);});
handle('video_done',d=>{updateVideoRow(jobId,d.video_id);updateProgressBar(d.done,d.total);refreshSidebar();});
handle('artifact_start',d=>{updateArtifactPill(d.video_id,d.artifact_type,'running');appendLog(d.job_id,'INFO','['+d.artifact_type+'] Starting attempt '+(d.attempt||1));});
handle('artifact_done',d=>{updateArtifactPill(d.video_id,d.artifact_type,'done');appendLog(d.job_id,'INFO','['+d.artifact_type+'] Done: '+d.file_path);});
handle('artifact_failed',d=>{updateArtifactPill(d.video_id,d.artifact_type,'failed');appendLog(d.job_id,'ERROR','['+d.artifact_type+'] FAILED: '+d.error);});
handle('artifact_skip',d=>{updateArtifactPill(d.video_id,d.artifact_type,'done');});
handle('progress',d=>{appendLog(jobId,'INFO',d.message);});
handle('stream_end',()=>{es.close();state.sseSource=null;refreshJobDetail(jobId);});
es.onerror=()=>{if(es.readyState===EventSource.CLOSED){state.sseSource=null;}};
}
// ── Job Detail ────────────────────────────────────────────────────
async function loadJobDetail(jobId){
state.selectedJobId=jobId;
try{
const [job,videos]=await Promise.all([apiFetch('/api/jobs/'+jobId),apiFetch('/api/jobs/'+jobId+'/videos')]);
renderJobDetail(job,videos);
if(job.status==='running'&&!state.sseSource) subscribeSSE(jobId);
if(['running','paused'].includes(job.status)&&!state.pollTimer){
state.pollTimer=setInterval(()=>refreshJobDetail(jobId),8000);
}
}catch(e){document.getElementById('job-detail').innerHTML='<div class="empty-state"><div class="empty-state-title text-red">Error loading job</div><div>'+escH(e.message)+'</div></div>';}
}
async function refreshJobDetail(jobId){
if(state.currentView!=='jobs'||state.selectedJobId!==jobId)return;
try{
const [job,videos]=await Promise.all([apiFetch('/api/jobs/'+jobId),apiFetch('/api/jobs/'+jobId+'/videos')]);
updateJobHeader(job);
updateVideoTableRows(jobId,videos);
if(['done','done_with_errors','stopped','failed'].includes(job.status)){
clearInterval(state.pollTimer);state.pollTimer=null;
}
}catch(e){}
}
function renderJobDetail(job,videos){
const jd=document.getElementById('job-detail');
const stats=job;
const pct=stats.total_videos>0?Math.round(stats.done_videos/stats.total_videos*100):0;
const isActive=['running','paused'].includes(job.status);
jd.innerHTML=
'<div id="jd-header">'+
'<div class="card">'+
'<div class="card-header">'+
'<div class="flex-1">'+
'<div class="flex-row" style="margin-bottom:6px">'+
'<span class="card-title">Job: </span>'+
'<span class="monospace text-dim" style="font-size:12px">'+job.job_id+'</span>'+
'<span class="badge badge-'+job.status+'" id="jd-badge">'+job.status.replace('_',' ')+'</span>'+
'</div>'+
'<div class="text-dim" style="font-size:12px">📁 '+escH(job.output_folder)+'</div>'+
'<div class="text-muted" style="font-size:11px">Input: '+job.input_type+' | Artifacts: '+(job.selections||[]).join(', ')+'</div>'+
'</div>'+
'</div>'+
'<div class="job-controls">'+
(job.status==='running'?
'<button class="btn btn-outline btn-sm" onclick="pauseJob(\''+job.job_id+'\')">⏸ Pause</button>'+
'<button class="btn btn-outline btn-sm" onclick="skipVideo(\''+job.job_id+'\')">⏭ Skip Video</button>'+
'<button class="btn btn-danger btn-sm" onclick="stopJob(\''+job.job_id+'\')">⏹ Stop</button>'
:job.status==='paused'?
'<button class="btn btn-green btn-sm" onclick="resumeJob(\''+job.job_id+'\')">▶ Resume</button>'+
'<button class="btn btn-danger btn-sm" onclick="stopJob(\''+job.job_id+'\')">⏹ Stop</button>'
:(job.status==='stopped'||job.status==='failed'||job.status==='done_with_errors')?
'<button class="btn btn-blue btn-sm" onclick="resumeJob(\''+job.job_id+'\')">↩ Resume from Checkpoint</button>'
:'')+
'<a class="btn btn-outline btn-sm" href="/api/jobs/'+job.job_id+'/logs/csv" download>⬇ Download Log CSV</a>'+
'</div>'+
'<div class="stats-row" style="margin:12px 0">'+
'<div class="stat-item"><div class="stat-value" id="jd-done">'+stats.done_videos+'</div><div class="stat-label">Videos Done</div></div>'+
'<div class="stat-item"><div class="stat-value">'+stats.total_videos+'</div><div class="stat-label">Total Videos</div></div>'+
'<div class="stat-item"><div class="stat-value text-green" id="jd-art-done">'+stats.artifact_done+'</div><div class="stat-label">Artifacts OK</div></div>'+
'<div class="stat-item"><div class="stat-value text-red" id="jd-art-fail">'+stats.artifact_failed+'</div><div class="stat-label">Artifacts Failed</div></div>'+
'<div class="stat-item"><div class="stat-value text-dim" id="jd-art-pend">'+stats.artifact_pending+'</div><div class="stat-label">Pending</div></div>'+
'</div>'+
'<div class="progress-bar"><div class="progress-fill '+(pct===100?'green':pct>0?'blue':'')+'" id="jd-progress" style="width:'+pct+'%"></div></div>'+
'<div style="font-size:11px;color:var(--text-muted);text-align:right" id="jd-pct">'+pct+'% complete</div>'+
'</div>'+
'</div>'+
'<div class="view-tabs">'+
'<button class="view-tab active" onclick="switchTab(\'videos\',this)">Videos ('+videos.length+')</button>'+
'<button class="view-tab" onclick="switchTab(\'logs\',this)">Live Log</button>'+
'</div>'+
'<div id="tab-videos">'+
'<div class="video-table-wrap"><table class="video-table" id="video-table">'+
'<thead><tr><th>#</th><th>Video ID</th><th>Title</th><th>Status</th><th>Artifacts</th></tr></thead>'+
'<tbody id="video-tbody">'+renderVideoRows(videos)+'</tbody>'+
'</table></div>'+
'</div>'+
'<div id="tab-logs" class="hidden">'+
'<div class="log-panel" id="log-panel"></div>'+
'<div style="margin-top:8px"><button class="btn btn-outline btn-sm" onclick="loadLogs(\''+job.job_id+'\')">🔄 Refresh Logs</button></div>'+
'</div>';
}
function switchTab(name,btn){
document.querySelectorAll('.view-tab').forEach(b=>b.classList.remove('active'));btn.classList.add('active');
document.getElementById('tab-videos').classList.toggle('hidden',name!=='videos');
document.getElementById('tab-logs').classList.toggle('hidden',name!=='logs');
if(name==='logs') loadLogs(state.selectedJobId);
}
function updateJobHeader(job){
const badge=document.getElementById('jd-badge');if(badge){badge.className='badge badge-'+job.status;badge.textContent=job.status.replace('_',' ');}
const done=document.getElementById('jd-done');if(done)done.textContent=job.done_videos||0;
const ad=document.getElementById('jd-art-done');if(ad)ad.textContent=job.artifact_done||0;
const af=document.getElementById('jd-art-fail');if(af)af.textContent=job.artifact_failed||0;
const ap=document.getElementById('jd-art-pend');if(ap)ap.textContent=job.artifact_pending||0;
const total=job.total_videos||1;const pct=Math.round((job.done_videos||0)/total*100);
const pb=document.getElementById('jd-progress');if(pb)pb.style.width=pct+'%';
const pp=document.getElementById('jd-pct');if(pp)pp.textContent=pct+'% complete';
}
function updateProgressBar(done,total){
const pct=total>0?Math.round(done/total*100):0;
const pb=document.getElementById('jd-progress');if(pb)pb.style.width=pct+'%';
const pp=document.getElementById('jd-pct');if(pp)pp.textContent=pct+'% complete';
const dd=document.getElementById('jd-done');if(dd)dd.textContent=done;
}
// ── Video Table Helpers ───────────────────────────────────────────
function renderVideoRows(videos){
if(!videos||!videos.length) return '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px">No videos loaded yet…</td></tr>';
return videos.map((v,i)=>{
const arts=(v.artifacts||[]);
const rowStatus=v.status||'pending';
return '<tr id="vrow-'+v.video_id+'" class="'+(state.currentVideoId===v.video_id?'active-row':'')+'">'+
'<td class="text-muted">'+((v.position||i)+1)+'</td>'+
'<td><span class="video-id-badge"><a href="https://youtube.com/watch?v='+v.video_id+'" target="_blank">'+v.video_id+'</a></span></td>'+
'<td class="video-title-cell" id="vtitle-'+v.video_id+'">'+(v.title?escH(v.title):'<span class="text-muted">Loading…</span>')+'</td>'+
'<td><span class="badge badge-'+rowStatus+'">'+rowStatus+'</span></td>'+
'<td><div class="artifact-pills" id="vpills-'+v.video_id+'">'+renderArtPills(arts)+'</div></td>'+
'</tr>';
}).join('');
}
function renderArtPills(arts){
return arts.map(a=>{
const cls='art-pill art-pill-'+a.status;
const m=AM[a.artifact_type]||{icon:'?'};
const title=a.error_msg?'Error: '+a.error_msg:(a.file_path?'File: '+a.file_path:'');
return '<span class="'+cls+'" title="'+escH(title)+'"><span class="art-dot art-dot-'+a.status+'"></span>'+m.icon+(a.artifact_type?'':a.artifact_type)+'</span>';
}).join('');
}
function renderVideoTable(jobId,videos){
const tbody=document.getElementById('video-tbody');
if(tbody) tbody.innerHTML=renderVideoRows(videos);
}
function updateVideoTableRows(jobId,videos){
videos.forEach(v=>{
const row=document.getElementById('vrow-'+v.video_id);
if(!row) return;
const tc=row.querySelector('.badge');if(tc){tc.className='badge badge-'+v.status;tc.textContent=v.status;}
const pills=document.getElementById('vpills-'+v.video_id);
if(pills) pills.innerHTML=renderArtPills(v.artifacts||[]);
const titleEl=document.getElementById('vtitle-'+v.video_id);
if(titleEl&&v.title) titleEl.textContent=v.title;
});
}
function updateVideoRow(jobId,videoId){
apiFetch('/api/jobs/'+jobId+'/videos').then(videos=>{
const v=videos.find(x=>x.video_id===videoId);if(!v)return;
const row=document.getElementById('vrow-'+videoId);if(!row)return;
const tc=row.querySelector('.badge');if(tc){tc.className='badge badge-'+v.status;tc.textContent=v.status;}
const pills=document.getElementById('vpills-'+videoId);
if(pills)pills.innerHTML=renderArtPills(v.artifacts||[]);
}).catch(()=>{});
}
function highlightVideoRow(videoId){
document.querySelectorAll('.video-table tr').forEach(r=>r.classList.remove('active-row'));
const r=document.getElementById('vrow-'+videoId);if(r){r.classList.add('active-row');r.scrollIntoView({block:'nearest'});}
}
function updateArtifactPill(videoId,artType,status){
const pills=document.getElementById('vpills-'+videoId);if(!pills)return;
const existing=pills.querySelector('[data-art="'+artType+'"]');
if(existing){existing.className='art-pill art-pill-'+status;existing.querySelector('.art-dot').className='art-dot art-dot-'+status;}
}
function updateVideoTitle(videoId,title){
const el=document.getElementById('vtitle-'+videoId);if(el)el.textContent=title;
}
// ── Live Log Panel ────────────────────────────────────────────────
async function loadLogs(jobId){
if(!jobId)return;
try{
const logs=await apiFetch('/api/jobs/'+jobId+'/logs?limit=200');
const panel=document.getElementById('log-panel');if(!panel)return;
panel.innerHTML=logs.map(l=>{
const ts=l.timestamp?l.timestamp.replace('T',' ').substring(0,19):'';
return '<div class="log-entry">'+
'<span class="log-ts">'+ts+'</span>'+
'<span class="log-level log-level-'+l.level+'">'+l.level+'</span>'+
'<span class="log-msg">'+escH(l.message)+'</span>'+
'</div>';
}).join('');
panel.scrollTop=panel.scrollHeight;
}catch(e){}
}
function appendLog(jobId,level,msg){
const panel=document.getElementById('log-panel');if(!panel)return;
const ts=new Date().toISOString().replace('T',' ').substring(0,19);
const div=document.createElement('div');div.className='log-entry';
div.innerHTML='<span class="log-ts">'+ts+'</span><span class="log-level log-level-'+level+'">'+level+'</span><span class="log-msg">'+escH(msg)+'</span>';
panel.appendChild(div);
if(panel.children.length>400) panel.removeChild(panel.children[0]);
panel.scrollTop=panel.scrollHeight;
}
// ── Sidebar ──────────────────────────────────────────────────────
async function refreshSidebar(){
try{
const jobs=await apiFetch('/api/jobs');
state.jobs=jobs;
const list=document.getElementById('sidebar-job-list');
if(!list)return;
if(!jobs||!jobs.length){list.innerHTML='<div class="text-muted" style="font-size:12px;padding:8px 4px">No jobs yet</div>';return;}
list.innerHTML=jobs.slice(0,15).map(j=>{
const icons={running:'🔵',done:'✅',paused:'⏸',stopped:'⏹',failed:'❌',done_with_errors:'⚠',pending:'🟣'};
const icon=icons[j.status]||'●';
const pct=j.total_videos>0?Math.round((j.done_videos||0)/j.total_videos*100):0;
return '<div class="job-item'+(j.job_id===state.selectedJobId?' active':'')+'" onclick="selectJob(\''+j.job_id+'\')">'+
'<div class="job-item-title">'+icon+' '+escH(j.input_type.toUpperCase())+' - '+j.job_id.substring(0,12)+'…</div>'+
'<div class="job-item-meta"><span>'+j.done_videos+'/'+j.total_videos+' videos</span><span>'+pct+'%</span></div>'+
'</div>';
}).join('');
}catch(e){}
}
function selectJob(jobId){
if(state.sseSource){state.sseSource.close();state.sseSource=null;}
clearInterval(state.pollTimer);state.pollTimer=null;
showView('jobs');
loadJobDetail(jobId);
refreshSidebar();
}
// ── Settings ──────────────────────────────────────────────────────
function renderSettings(){
apiFetch('/api/config').then(cfg=>{
const s=state.settings;
const dm=document.getElementById('s-delay-min');if(dm)dm.value=s.delay_min||cfg.delay_min||2;
const dx=document.getElementById('s-delay-max');if(dx)dx.value=s.delay_max||cfg.delay_max||7;
const sm=document.getElementById('s-model');if(sm)sm.value=s.model||cfg.default_model||'openai/gpt-4o-mini';
const sp=document.getElementById('s-prompt');if(sp)sp.value=s.prompt||cfg.summary_prompt||'';
const snippet=document.getElementById('mcp-config-snippet');
if(snippet){
const mcpCfg={mcpServers:{"youtube-sipoc":{command:"python",args:[window.location.pathname.replace('/','')+'mcp_server.py']}}};
snippet.textContent=JSON.stringify(mcpCfg,null,2);
}
}).catch(()=>{});
}
function saveSettings(){
state.settings={
delay_min:parseFloat(document.getElementById('s-delay-min').value)||2,
delay_max:parseFloat(document.getElementById('s-delay-max').value)||7,
model:document.getElementById('s-model').value.trim(),
prompt:document.getElementById('s-prompt').value.trim(),
};
localStorage.setItem('sipoc_settings',JSON.stringify(state.settings));
// Apply to new job form
const dm=document.getElementById('delay-min');if(dm)dm.value=state.settings.delay_min;
const dx=document.getElementById('delay-max');if(dx)dx.value=state.settings.delay_max;
const mo=document.getElementById('or-model');if(mo)mo.value=state.settings.model;
const sp=document.getElementById('summary-prompt');if(sp)sp.value=state.settings.prompt;
toast('Settings saved','success');
}
function copyMcpConfig(){
const el=document.getElementById('mcp-config-snippet');if(!el)return;
navigator.clipboard.writeText(el.textContent).then(()=>toast('MCP config copied!','success')).catch(()=>toast('Copy failed','error'));
}
function loadSettings(){
const saved=localStorage.getItem('sipoc_settings');
if(saved){try{state.settings=JSON.parse(saved);}catch(e){}}
}
// ── Toast Notifications ───────────────────────────────────────────
function toast(msg,type='info'){
const c=document.getElementById('toast-container');if(!c)return;
const d=document.createElement('div');d.className='toast '+type;
const icons={success:'✅',error:'❌',info:'ℹ'};
d.innerHTML='<span>'+(icons[type]||'ℹ')+'</span><span class="toast-msg">'+escH(msg)+'</span>';
c.appendChild(d);
setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity 0.4s';setTimeout(()=>d.remove(),400);},4000);
}
// ── Init ──────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded',()=>{
loadSettings();
renderArtifactGrid();
apiFetch('/api/config').then(cfg=>{
if(!state.settings.model){const mo=document.getElementById('or-model');if(mo)mo.value=cfg.default_model||'openai/gpt-4o-mini';}
if(!state.settings.delay_min){const dm=document.getElementById('delay-min');if(dm)dm.value=cfg.delay_min||2;}
if(!state.settings.delay_max){const dx=document.getElementById('delay-max');if(dx)dx.value=cfg.delay_max||7;}
if(!state.settings.prompt){const sp=document.getElementById('summary-prompt');if(sp)sp.value=cfg.summary_prompt||'';}
}).catch(()=>{});
refreshSidebar();
// Poll sidebar every 10s for job status updates
setInterval(refreshSidebar,10000);
// Handle Enter on folder input
const fi=document.getElementById('folder-modal-path');
if(fi)fi.addEventListener('keydown',e=>{if(e.key==='Enter')navigateToTyped();});
});