This commit is contained in:
kim
2026-05-05 00:51:37 +09:00
parent 9714d4b50c
commit 233b67b9a3
35 changed files with 3496 additions and 138 deletions
+248 -51
View File
@@ -207,6 +207,12 @@
overflow-x: hidden;
}
/* Collapsed (minimized to right rail) — grid columns are set via JS so the
mobile single-column media query keeps working. */
body.right-collapsed .right-panel > *:not(#right-panel-rail) { display: none !important; }
body.right-collapsed #right-panel-rail { display: flex !important; }
#right-panel-rail { display: none; }
aside {
grid-area: sidebar;
display: flex;
@@ -2569,13 +2575,23 @@
<div class="right-panel" id="right-panel" style="position:relative">
<!-- Resize handle on left edge -->
<div id="right-panel-resize-handle" style="position:absolute;left:0;top:0;bottom:0;width:5px;cursor:col-resize;z-index:200;background:transparent;transition:background 0.15s ease" title="드래그하여 크기 조절"></div>
<!-- Collapsed rail (visible when right panel is minimized) -->
<div id="right-panel-rail" onclick="toggleRightPanel()" title="컨텍스트 패널 펼치기" style="flex-direction:column;align-items:center;justify-content:flex-start;gap:14px;padding:10px 0;cursor:pointer;background:var(--panel);height:100%">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><polyline points="15 18 9 12 15 6"/></svg>
<div style="writing-mode:vertical-rl;font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.12em">컨텍스트</div>
</div>
<!-- Right panel topbar with canvas toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 12px;border-bottom:1px solid var(--line);flex-shrink:0;background:var(--panel);min-height:38px">
<span style="font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em">컨텍스트</span>
<button id="canvas-toggle-btn" onclick="toggleCanvas()" title="캔버스 전환" style="display:flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;transition:all 0.15s ease">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
Canvas
</button>
<div style="display:flex;align-items:center;gap:6px">
<button id="canvas-toggle-btn" onclick="toggleCanvas()" title="캔버스 전환" style="display:flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;transition:all 0.15s ease">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
Canvas
</button>
<button onclick="toggleRightPanel()" title="컨텍스트 패널 최소화" style="display:flex;align-items:center;justify-content:center;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;width:28px;height:28px;cursor:pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<!-- Canvas Overlay -->
@@ -3796,9 +3812,15 @@ let pollInterval = null;
let currentMode = 'chat';
let sidebarTab = 'jobs';
let useAgentMode = false;
const CHAT_SESSIONS_KEY = 'cherryclaw_chat_sessions_v1';
const AGENT_SESSION_KEY = 'cherryclaw_agent_session_id';
const CHAT_SESSIONS_KEY_BASE = 'cherryclaw_chat_sessions_v1';
const AGENT_SESSION_KEY_BASE = 'cherryclaw_agent_session_id';
const THEME_KEY = 'cherryclaw_theme';
const TOKEN_KEY = 'smallclaw_token';
function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } }
function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; }
function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; }
function generateSessionId() {
return (crypto?.randomUUID?.() || ('sess_' + Math.random().toString(36).slice(2)));
}
@@ -3811,7 +3833,7 @@ function getCookie(name) {
function setAgentSessionId(id) {
const next = String(id || '').trim() || generateSessionId();
agentSessionId = next;
localStorage.setItem(AGENT_SESSION_KEY, next);
localStorage.setItem(agentSessionKey(), next);
return next;
}
let agentSessionId = '';
@@ -3976,7 +3998,7 @@ function updateQueuedPromptUI() {
}
function saveChatSessions() {
localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(chatSessions));
localStorage.setItem(chatSessionsKey(), JSON.stringify(chatSessions));
}
function makeSessionTitle(history) {
@@ -3993,8 +4015,22 @@ function makeSessionTitle(history) {
}
function loadChatSessions() {
const key = chatSessionsKey();
try {
chatSessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || '[]');
const raw = localStorage.getItem(key);
if (raw) {
chatSessions = JSON.parse(raw);
} else {
// Migrate from old non-namespaced key once, then delete it so other users can't see it.
const oldRaw = localStorage.getItem(CHAT_SESSIONS_KEY_BASE);
if (oldRaw) {
chatSessions = JSON.parse(oldRaw);
localStorage.setItem(key, oldRaw);
localStorage.removeItem(CHAT_SESSIONS_KEY_BASE);
} else {
chatSessions = [];
}
}
} catch {
chatSessions = [];
}
@@ -4565,10 +4601,7 @@ async function openInFileLocation(targetPath) {
try {
const res = await fetch('/api/open-path', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path: p }),
});
const data = await res.json().catch(() => ({}));
@@ -4993,11 +5026,10 @@ async function sendChat(queuedMessage = null) {
const res = await fetch('/api/chat', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: authHeaders({
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
}),
signal: currentAbortController.signal,
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
});
@@ -5516,7 +5548,7 @@ function openProcessLogFile() {
// Call server endpoint to open the process log file
if (activeTaskId) {
fetch(`/api/open-file?task=${encodeURIComponent(activeTaskId)}&type=process`, {
credentials: 'same-origin'
headers: authHeaders()
})
.catch(err => console.error('Unable to open process log:', err));
}
@@ -5626,7 +5658,9 @@ document.getElementById('chat-input').addEventListener('input', function() {
// ---- WebSocket ----
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws`);
const token = getAuthToken();
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws${qs}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'jobs_update') {
@@ -5716,7 +5750,7 @@ function connectWS() {
(async () => {
try {
const data = await fetch(`/api/bg-tasks/${msg.taskId}`, {
credentials: 'same-origin'
headers: authHeaders()
}).then(r => r.json());
const task = data?.task || data;
if (task && task.sessionId && task.sessionId.startsWith('cron_')) {
@@ -5794,9 +5828,9 @@ function connectWS() {
// ---- API ----
async function api(path, opts = {}) {
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
const headers = { 'Content-Type': 'application/json', ...optsHeaders };
const r = await fetch(API + path, { credentials: 'same-origin', headers, ...restOpts });
if (r.status === 401) { window.location.href = '/login.html'; throw new Error('Authentication required'); }
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
const r = await fetch(API + path, { headers, ...restOpts });
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
const ct = String(r.headers.get('content-type') || '').toLowerCase();
const bodyText = await r.text();
@@ -5825,8 +5859,8 @@ const LAST_USER_KEY = 'cherryclaw_last_user';
function clearLocalUserState(reason) {
try {
localStorage.removeItem(CHAT_SESSIONS_KEY);
localStorage.removeItem(AGENT_SESSION_KEY);
localStorage.removeItem(chatSessionsKey());
localStorage.removeItem(agentSessionKey());
} catch {}
try {
chatSessions = [];
@@ -5843,7 +5877,7 @@ function clearLocalUserState(reason) {
async function checkAuth() {
try {
const res = await fetch('/api/auth/status');
const res = await fetch('/api/auth/status', { headers: authHeaders() });
const data = await res.json();
if (data.enabled && !data.authenticated && !data.hasPassword) {
window.location.href = '/login.html';
@@ -5856,6 +5890,10 @@ async function checkAuth() {
if (data.authenticated && data.username) {
currentUser = { username: data.username, role: data.role };
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
// This also migrates any data from the old non-namespaced key on first load after server restart.
if (typeof loadChatSessions === 'function') loadChatSessions();
// Multi-user safety: if a different user was logged in on this browser
// before, wipe the cached session list & active sessionId so the
// incoming user does not reuse the previous user's chat IDs.
@@ -5880,12 +5918,13 @@ async function checkAuth() {
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() });
} catch (e) { /* ignore network errors */ }
// Wipe cached chat list / sessionId so a different user logging in next
// does not inherit any of this user's session IDs.
clearLocalUserState('logout');
try { localStorage.removeItem(LAST_USER_KEY); } catch {}
setAuthToken('');
window.location.href = '/login.html';
}
@@ -6566,11 +6605,15 @@ async function loadUsersTab() {
return;
}
container.innerHTML = data.users.map(u => `
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)">
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
<div style="border-bottom:1px solid var(--line);padding:8px 0">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserEmail('${u.username}')">✉ 이메일</button>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
</div>
<div id="user-email-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
</div>
`).join('');
} catch (e) {
@@ -6608,6 +6651,134 @@ async function removeUser(username) {
} catch (e) { alert(e.message || '삭제 실패'); }
}
// --- Per-User Email Accounts -------------------------------------------------
function toggleUserEmail(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadUserEmailAccounts(username);
} else {
panel.style.display = 'none';
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
async function loadUserEmailAccounts(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중…</div>';
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
const accounts = data.accounts || [];
const def = data.default || '';
const formId = `email-form-${username}`;
const list = accounts.length === 0
? '<div style="font-size:12px;color:var(--muted);margin-bottom:8px">등록된 이메일 계정이 없습니다.</div>'
: accounts.map(a => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:12px">
<strong style="min-width:90px">${escapeHtml(a.id)}</strong>
${def === a.id ? '<span style="font-size:10px;background:var(--brand);color:#fff;padding:1px 6px;border-radius:6px">기본</span>' : ''}
<span style="color:var(--muted);flex:1;font-family:'IBM Plex Mono',monospace;font-size:11px">${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port}</span>
${a.password_set ? '' : '<span style="font-size:10px;color:var(--err)">비번 없음</span>'}
${def !== a.id ? `<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="setDefaultUserEmailAccount('${username}','${escapeHtml(a.id)}')">기본 설정</button>` : ''}
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="testUserEmailAccount('${username}','${escapeHtml(a.id)}',this)" ${a.password_set ? '' : 'disabled'}>테스트</button>
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer" onclick="deleteUserEmailAccount('${username}','${escapeHtml(a.id)}')">삭제</button>
</div>`).join('');
panel.innerHTML = `
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">이메일 계정 (${accounts.length})</div>
${list}
<details style="margin-top:8px"><summary style="font-size:12px;cursor:pointer;color:var(--muted)">+ 계정 추가/수정</summary>
<div id="${formId}" style="margin-top:8px;padding:10px;background:#fff;border:1px solid var(--line);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px">
<label style="grid-column:1/3"><span style="color:var(--muted);font-size:11px">계정 ID (예: applecherry)</span>
<input data-f="id" type="text" placeholder="applecherry" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 호스트</span>
<input data-f="imap_host" type="text" placeholder="imap.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 포트</span>
<input data-f="imap_port" type="number" value="993" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 호스트</span>
<input data-f="smtp_host" type="text" placeholder="smtp.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 포트</span>
<input data-f="smtp_port" type="number" value="587" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">사용자명 (이메일)</span>
<input data-f="username" type="text" placeholder="user@example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">비밀번호 (vault에 암호화 저장)</span>
<input data-f="password" type="password" autocomplete="new-password" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="imap_tls" type="checkbox" checked /> IMAP TLS</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="smtp_secure" type="checkbox" /> SMTP secure (465)</label>
<button class="btn btn-primary" style="grid-column:1/3;padding:7px;margin-top:6px;font-size:12px" onclick="submitUserEmailAccount('${username}')">저장</button>
<div id="${formId}-msg" style="grid-column:1/3;font-size:11px;color:var(--err)"></div>
</div>
</details>`;
} catch (e) {
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">불러오기 실패: ${escapeHtml(e.message || e)}</div>`;
}
}
async function submitUserEmailAccount(username) {
const form = document.getElementById(`email-form-${username}`);
const msg = document.getElementById(`email-form-${username}-msg`);
if (!form) return;
const get = (k) => form.querySelector(`[data-f="${k}"]`);
const id = (get('id').value || '').trim();
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { msg.textContent = '계정 ID는 1-32자 영숫자/대시/언더스코어'; return; }
const body = {
imap_host: get('imap_host').value.trim(),
imap_port: Number(get('imap_port').value) || 993,
imap_tls: get('imap_tls').checked,
smtp_host: get('smtp_host').value.trim(),
smtp_port: Number(get('smtp_port').value) || 587,
smtp_secure: get('smtp_secure').checked,
username: get('username').value.trim(),
password: get('password').value,
};
if (!body.imap_host || !body.smtp_host || !body.username) { msg.textContent = 'IMAP/SMTP 호스트와 사용자명은 필수'; return; }
msg.textContent = '저장 중…'; msg.style.color = 'var(--muted)';
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
method: 'PUT', body: JSON.stringify(body),
});
loadUserEmailAccounts(username);
} catch (e) {
msg.textContent = e.message || '저장 실패'; msg.style.color = 'var(--err)';
}
}
async function deleteUserEmailAccount(username, accountId) {
if (!confirm(`계정 "${accountId}" 삭제하시겠습니까? (vault의 비밀번호도 함께 삭제됩니다)`)) return;
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '삭제 실패'); }
}
async function setDefaultUserEmailAccount(username, accountId) {
try {
await api(`/api/users/${encodeURIComponent(username)}/email/default`, {
method: 'PUT', body: JSON.stringify({ accountId }),
});
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '기본 설정 실패'); }
}
async function testUserEmailAccount(username, accountId, btn) {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '연결 중…';
try {
const r = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST' });
if (r.success) { btn.textContent = '✓ 성공'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); }
else { alert(`테스트 실패: ${r.error || 'unknown'}`); btn.textContent = orig; btn.disabled = false; }
} catch (e) {
alert(e.message || '테스트 실패'); btn.textContent = orig; btn.disabled = false;
}
}
// --- Context Viewer Tab -------------------------------------------------------
let cvAutoRefreshTimer = null;
@@ -6759,10 +6930,7 @@ async function savePptPrefs() {
try {
await fetch('/api/settings/ppt', {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ template: selectedPptTemplate, skin: selectedPptSkin }),
});
} catch (e) { console.error('savePptPrefs', e); }
@@ -8139,7 +8307,7 @@ function toggleXlsxEmbedBtn(btn) {
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.innerHTML = '불러오는 중…';
fetch(url, { credentials: 'same-origin' })
fetch(url, { headers: authHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
@@ -8194,7 +8362,7 @@ function toggleTxtEmbedBtn(btn) {
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.textContent = '불러오는 중…';
fetch(url, { credentials: 'same-origin' })
fetch(url, { headers: authHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
@@ -8303,7 +8471,7 @@ function validateDownloadLinks(container) {
if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; }
if (_dlLinkCache[url] === true) return;
_dlLinkCache[url] = 'pending';
fetch(url, { method: 'HEAD', credentials: 'same-origin' })
fetch(url, { method: 'HEAD', headers: authHeaders() })
.then(function(r) {
_dlLinkCache[url] = r.ok;
if (!r.ok) {
@@ -8335,7 +8503,7 @@ function downloadFile(e, el) {
var filename = decodeURIComponent(url.split('/').pop() || 'download');
fetch(url, {
credentials: 'same-origin'
headers: authHeaders()
})
.then(function(r) {
if (!r.ok) throw new Error(r.status);
@@ -8391,7 +8559,7 @@ function togglePptxPreview(fileUrl, containerId, btn) {
// Safely decode - handle already-decoded or invalid encodings
try { filePath = decodeURIComponent(filePath); } catch(e) {}
fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), {
credentials: 'same-origin'
headers: authHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -8418,7 +8586,7 @@ function handleImageUpload(input) {
formData.append('image', file);
fetch('/api/upload/image', {
method: 'POST',
credentials: 'same-origin',
headers: authHeaders(),
body: formData
})
.then(function(r) { return r.json(); })
@@ -8446,7 +8614,7 @@ function handleFileUpload(input) {
if (file.type.startsWith('image/')) {
var imgForm = new FormData();
imgForm.append('image', file);
fetch('/api/upload/image', { method: 'POST', credentials: 'same-origin', body: imgForm })
fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: imgForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
@@ -8477,7 +8645,7 @@ function handleFileUpload(input) {
var xlsxFile = file;
var xlsxForm = new FormData();
xlsxForm.append('file', xlsxFile);
var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: xlsxForm })
var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: xlsxForm })
.then(function(r) { return r.json(); }).catch(function() { return {}; });
loadSheetJS(function() {
var r3 = new FileReader();
@@ -8518,7 +8686,7 @@ function handleFileUpload(input) {
var pdfFile = file;
var pdfForm = new FormData();
pdfForm.append('file', pdfFile);
var uploadPromise = fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: pdfForm })
var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm })
.then(function(r) { return r.json(); });
var extractPromise = new Promise(function(resolve) {
@@ -8554,7 +8722,7 @@ function handleFileUpload(input) {
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
var txtForm = new FormData();
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm })
.then(function(r) { return r.json(); })
.then(function(d) {
var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
@@ -8585,7 +8753,7 @@ function handleFileUpload(input) {
} else {
var otherForm = new FormData();
otherForm.append('file', file);
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: otherForm })
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
@@ -10618,8 +10786,7 @@ async function submitErrorResponse() {
try {
const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action, category, inputs }),
});
const data = await resp.json();
@@ -10748,6 +10915,34 @@ function mobCloseMenu() {
document.getElementById('mob-tab-menu')?.classList.remove('active');
}
// ── Right-panel collapse ────────────────────────────────────────────────────
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
function applyRightPanelCollapsed(collapsed) {
document.body.classList.toggle('right-collapsed', !!collapsed);
// Only override on desktop — narrow viewports use the mobile single-column layout.
const isDesktop = window.matchMedia('(min-width: 980px)').matches;
if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; }
if (collapsed) {
document.body.style.gridTemplateColumns = '280px 1fr 36px';
} else {
const saved = parseInt(localStorage.getItem('rightPanelWidth'), 10);
const max = Math.floor(window.innerWidth * 0.65);
const w = saved && saved >= 280 ? Math.min(saved, max) : '';
document.body.style.gridTemplateColumns = w ? `280px 1fr ${w}px` : '';
}
}
function toggleRightPanel() {
const next = !document.body.classList.contains('right-collapsed');
localStorage.setItem(RIGHT_COLLAPSE_KEY, next ? '1' : '0');
applyRightPanelCollapsed(next);
}
// Default to collapsed when no preference saved.
(function initRightPanelCollapse() {
const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
const collapsed = stored === null ? true : stored === '1';
applyRightPanelCollapsed(collapsed);
})();
// ── Right-panel resize ──────────────────────────────────────────────────────
(function initRightPanelResize() {
const MIN_W = 280;
@@ -10762,9 +10957,11 @@ function mobCloseMenu() {
return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px));
}
// Restore saved width
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
// Restore saved width (only if not collapsed)
if (!document.body.classList.contains('right-collapsed')) {
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
}
function setup() {
const handle = document.getElementById('right-panel-resize-handle');