v2.4.1
This commit is contained in:
+248
-51
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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 = '▲ 접기';
|
||||
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 = '▲ 접기';
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user