v2.2
This commit is contained in:
+340
-9
@@ -2302,9 +2302,11 @@
|
||||
</div>
|
||||
<div class="queued-prompts-list" id="queued-prompts-list"></div>
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
<input type="file" id="image-upload" accept="image/*" style="display:none" onchange="handleImageUpload(this)" />
|
||||
<button class="send-btn" onclick="document.getElementById('image-upload').click()" title="이미지 첨부" style="font-size:18px;width:44px;height:44px">📎</button>
|
||||
<!-- 첨부 파일 카드 영역 (전송 전 표시) -->
|
||||
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
|
||||
<div class="chat-input-row">
|
||||
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx" style="display:none" onchange="handleFileUpload(this)" />
|
||||
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
|
||||
@@ -4418,7 +4420,7 @@ function renderChatMessages() {
|
||||
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}</div>
|
||||
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.content)}</div>`}
|
||||
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : msg.content)}</div>`}
|
||||
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
|
||||
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
|
||||
${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''}
|
||||
@@ -4702,7 +4704,7 @@ async function sendChat(queuedMessage = null) {
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value;
|
||||
const message = String(raw || '').trim();
|
||||
if (!message) return;
|
||||
if (!message && stagedAttachments.length === 0) return;
|
||||
if (isThinking) {
|
||||
if (typeof queuedMessage === 'string') return;
|
||||
if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
|
||||
@@ -4717,7 +4719,31 @@ async function sendChat(queuedMessage = null) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatHistory.push({ role: 'user', content: message });
|
||||
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
|
||||
let fullMessage = message;
|
||||
let displayMessage = message;
|
||||
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
|
||||
const attachParts = stagedAttachments
|
||||
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
|
||||
.map(function(a) {
|
||||
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
|
||||
});
|
||||
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : '');
|
||||
const compactParts = stagedAttachments
|
||||
.filter(function(a) { return a.type !== 'txt-auto'; })
|
||||
.map(function(a) {
|
||||
// [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링
|
||||
var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**';
|
||||
// PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가
|
||||
if (a.type === 'pdf' && a.txtUrl) {
|
||||
line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')';
|
||||
}
|
||||
return line;
|
||||
});
|
||||
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : '');
|
||||
clearStagedAttachments();
|
||||
}
|
||||
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
|
||||
persistActiveChat();
|
||||
if (typeof queuedMessage !== 'string') {
|
||||
input.value = '';
|
||||
@@ -4784,7 +4810,7 @@ async function sendChat(queuedMessage = null) {
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
signal: currentAbortController.signal,
|
||||
body: JSON.stringify({ message, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
|
||||
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -7599,16 +7625,32 @@ function _renderLinkButton(text, url) {
|
||||
var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3'];
|
||||
var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); })
|
||||
|| fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; });
|
||||
var isPptx = decodeURIComponent(url).toLowerCase().indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
|
||||
var decodedUrl = decodeURIComponent(url).toLowerCase();
|
||||
var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
|
||||
var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf');
|
||||
if (isPptx) {
|
||||
var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9);
|
||||
return '<div class="pptx-file-row">'
|
||||
+ '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
|
||||
+ '<span class="download-icon">⬇</span> ' + text + '</a>'
|
||||
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="Preview slides">◀ Preview</button>'
|
||||
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="미리보기">◀ 미리보기</button>'
|
||||
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
if (isPdf) {
|
||||
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
|
||||
return '<div class="pdf-file-row" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
|
||||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||||
+ '<a href="' + url + '" target="_blank" rel="noopener" class="download-btn" title="새 탭에서 열기">'
|
||||
+ '<span style="font-size:14px">📄</span> ' + text + '</a>'
|
||||
+ '<button class="pptx-preview-btn" onclick="togglePdfEmbed(\'' + escHtml(url) + '\',\'' + pdfId + '\',this)">◀ 미리보기</button>'
|
||||
+ '<a href="' + url + '" onclick="downloadFile(event,this)" class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</a>'
|
||||
+ '</div>'
|
||||
+ '<div id="' + pdfId + '" style="display:none">'
|
||||
+ '<iframe style="width:100%;height:600px;border:1px solid var(--line);border-radius:8px" allowfullscreen></iframe>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
if (isDownload) {
|
||||
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
|
||||
+ '<span class="download-icon">⬇</span> ' + text + '</a>';
|
||||
@@ -7616,6 +7658,20 @@ function _renderLinkButton(text, url) {
|
||||
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
|
||||
}
|
||||
|
||||
function togglePdfEmbed(url, containerId, btn) {
|
||||
var container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
var iframe = container.querySelector('iframe');
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정
|
||||
btn.innerHTML = '▲ 닫기';
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
btn.innerHTML = '◀ 미리보기';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
|
||||
@@ -7803,6 +7859,281 @@ function handleImageUpload(input) {
|
||||
.catch(function(e) { alert('Upload error: ' + e.message); });
|
||||
}
|
||||
|
||||
// ── 파일 첨부 (이미지·PDF·Excel·txt·csv 등) ─────────────────────────────────
|
||||
function handleFileUpload(input) {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
input.value = '';
|
||||
var name = file.name.toLowerCase();
|
||||
|
||||
// 이미지
|
||||
if (file.type.startsWith('image/')) {
|
||||
var imgForm = new FormData();
|
||||
imgForm.append('image', file);
|
||||
fetch('/api/upload/image', { method: 'POST', credentials: 'same-origin', body: imgForm })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success && data.path) {
|
||||
insertIntoChatInput('');
|
||||
} else { alert(data.error || '이미지 업로드 실패'); }
|
||||
})
|
||||
.catch(function(e) { alert('업로드 오류: ' + e.message); });
|
||||
|
||||
// txt / md
|
||||
} else if (/\.(txt|md)$/.test(name)) {
|
||||
var r1 = new FileReader();
|
||||
r1.onload = function(e) {
|
||||
var ext = name.split('.').pop();
|
||||
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```' + ext + '\n' + e.target.result + '\n```', 'text');
|
||||
};
|
||||
r1.readAsText(file, 'UTF-8');
|
||||
|
||||
// CSV
|
||||
} else if (/\.csv$/.test(name)) {
|
||||
var r2 = new FileReader();
|
||||
r2.onload = function(e) {
|
||||
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```csv\n' + e.target.result + '\n```', 'csv');
|
||||
};
|
||||
r2.readAsText(file, 'UTF-8');
|
||||
|
||||
// Excel
|
||||
} else if (/\.(xlsx|xls)$/.test(name)) {
|
||||
loadSheetJS(function() {
|
||||
var r3 = new FileReader();
|
||||
r3.onload = function(e) {
|
||||
try {
|
||||
var wb = XLSX.read(e.target.result, { type: 'array' });
|
||||
var result = '**[파일: ' + file.name + ']**\n\n';
|
||||
wb.SheetNames.forEach(function(sn) {
|
||||
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
|
||||
result += '### ' + sn + '\n';
|
||||
rows.forEach(function(row) { result += '| ' + row.join(' | ') + ' |\n'; });
|
||||
result += '\n';
|
||||
});
|
||||
stageAttachment(file.name, null, result, 'excel');
|
||||
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
|
||||
};
|
||||
r3.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
|
||||
} else if (/\.pdf$/.test(name)) {
|
||||
var pdfFile = file;
|
||||
var pdfForm = new FormData();
|
||||
pdfForm.append('file', pdfFile);
|
||||
var uploadPromise = fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: pdfForm })
|
||||
.then(function(r) { return r.json(); });
|
||||
|
||||
var extractPromise = new Promise(function(resolve) {
|
||||
loadPdfJs(function() {
|
||||
var fr = new FileReader();
|
||||
fr.onload = function(e) {
|
||||
pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise
|
||||
.then(function(pdf) {
|
||||
var pages = [];
|
||||
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
|
||||
Promise.all(pages).then(function(ps) {
|
||||
return Promise.all(ps.map(function(p) {
|
||||
return p.getTextContent().then(function(tc) {
|
||||
return tc.items.map(function(it) { return it.str; }).join(' ');
|
||||
});
|
||||
}));
|
||||
}).then(function(texts) {
|
||||
resolve({ text: texts.join('\n\n'), pages: pdf.numPages });
|
||||
}).catch(function() { resolve({ text: '', pages: 0 }); });
|
||||
}).catch(function() { resolve({ text: '', pages: 0 }); });
|
||||
};
|
||||
fr.readAsArrayBuffer(pdfFile);
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all([uploadPromise, extractPromise]).then(function(res) {
|
||||
var upData = res[0], extracted = res[1];
|
||||
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
|
||||
var trimmed = (extracted.text || '').trim().slice(0, 8000);
|
||||
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n';
|
||||
if (trimmed) {
|
||||
content += '**[PDF 내용 — ' + extracted.pages + '페이지]**\n\n' + trimmed;
|
||||
if ((extracted.text || '').trim().length > 8000) content += '\n\n*(앞 8000자만 포함)*';
|
||||
|
||||
// PDF 텍스트를 .txt 파일로 자동 저장 → PDF 카드에 txt 버튼 추가
|
||||
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
|
||||
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' }));
|
||||
// PDF 카드 먼저 추가 (txt 저장 대기 없이)
|
||||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
|
||||
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.path) {
|
||||
var txtUrl = '/api/files/' + d.path;
|
||||
addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
|
||||
// 이미 스테이징된 PDF 카드에 txt 버튼 주입
|
||||
addTxtButtonToPdfCard(pdfFile.name, txtName, txtUrl);
|
||||
}
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
content += '*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
|
||||
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
|
||||
}
|
||||
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
|
||||
|
||||
// 기타 (docx/pptx 등)
|
||||
} else {
|
||||
var otherForm = new FormData();
|
||||
otherForm.append('file', file);
|
||||
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: otherForm })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success && data.path) {
|
||||
insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')');
|
||||
} else { alert(data.error || '파일 업로드 실패'); }
|
||||
})
|
||||
.catch(function(e) { alert('업로드 오류: ' + e.message); });
|
||||
}
|
||||
}
|
||||
|
||||
function insertIntoChatInput(text) {
|
||||
var el = document.getElementById('chat-input');
|
||||
el.value = el.value ? el.value + '\n' + text : text;
|
||||
el.focus();
|
||||
el.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
|
||||
var stagedAttachments = [];
|
||||
|
||||
function stageAttachment(name, url, content, type) {
|
||||
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
|
||||
stagedAttachments.push({ id: id, name: name, url: url, content: content, type: type });
|
||||
renderStagedAttachments();
|
||||
}
|
||||
|
||||
function removeStagedAttachment(id) {
|
||||
stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; });
|
||||
renderStagedAttachments();
|
||||
}
|
||||
|
||||
function clearStagedAttachments() {
|
||||
stagedAttachments = [];
|
||||
renderStagedAttachments();
|
||||
}
|
||||
|
||||
function renderStagedAttachments() {
|
||||
var container = document.getElementById('staged-attachments');
|
||||
if (!container) return;
|
||||
if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; }
|
||||
container.style.display = 'flex';
|
||||
container.innerHTML = stagedAttachments.map(function(a) {
|
||||
var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝';
|
||||
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px';
|
||||
|
||||
// PDF 카드
|
||||
if (a.type === 'pdf') {
|
||||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>📄</span>'
|
||||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||||
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
|
||||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||||
+ '</div>'
|
||||
+ '<div id="pdf-preview-' + a.id + '" style="display:none"><iframe data-src="' + escHtml(a.url || '') + '" style="width:100%;height:400px;border:1px solid var(--line);border-radius:6px"></iframe></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// TXT 자동저장 카드 (미리보기 + 다운로드)
|
||||
if (a.type === 'txt-auto') {
|
||||
var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : '');
|
||||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px dashed var(--brand);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>📝</span>'
|
||||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||||
+ '<button onclick="toggleStagedTxtPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>'
|
||||
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="다운로드">⬇ 저장</a>' : '')
|
||||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||||
+ '</div>'
|
||||
+ '<div id="txt-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + preview + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// 기타 카드 (text, csv, excel)
|
||||
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
|
||||
+ '<div style="display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>' + icon + '</span>'
|
||||
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
|
||||
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleStagedPdfPreview(id) {
|
||||
var el = document.getElementById('pdf-preview-' + id);
|
||||
if (!el) return;
|
||||
var att = stagedAttachments.find(function(a) { return a.id === id; });
|
||||
if (el.style.display === 'none') {
|
||||
el.style.display = 'block';
|
||||
var iframe = el.querySelector('iframe');
|
||||
if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStagedTxtPreview(id) {
|
||||
var el = document.getElementById('txt-preview-' + id);
|
||||
if (!el) return;
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
|
||||
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
|
||||
// stagedAttachments에서 해당 PDF 찾기
|
||||
var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; });
|
||||
if (!att) return;
|
||||
// txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용)
|
||||
att.txtName = txtName;
|
||||
att.txtUrl = txtUrl;
|
||||
// 카드 DOM에 txt 버튼 삽입
|
||||
var card = document.getElementById('card-' + att.id);
|
||||
if (!card) return;
|
||||
var btnRow = card.querySelector('div');
|
||||
if (!btnRow) return;
|
||||
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block';
|
||||
var txtBtn = document.createElement('a');
|
||||
txtBtn.href = txtUrl;
|
||||
txtBtn.download = txtName;
|
||||
txtBtn.title = txtName + ' 다운로드';
|
||||
txtBtn.style.cssText = btnStyle;
|
||||
txtBtn.innerHTML = '📝 txt ⬇';
|
||||
// ✕ 버튼 바로 앞에 삽입
|
||||
var closeBtn = btnRow.lastElementChild;
|
||||
btnRow.insertBefore(txtBtn, closeBtn);
|
||||
}
|
||||
|
||||
// ── SheetJS / PDF.js 동적 로드 ──────────────────────────────────────────────
|
||||
var _sheetJsLoaded = false;
|
||||
function loadSheetJS(cb) {
|
||||
if (_sheetJsLoaded) return cb();
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
|
||||
s.onload = function() { _sheetJsLoaded = true; cb(); };
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
var _pdfJsLoaded = false;
|
||||
function loadPdfJs(cb) {
|
||||
if (_pdfJsLoaded) return cb();
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||||
s.onload = function() {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
_pdfJsLoaded = true; cb();
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function timeAgo(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return 'just now';
|
||||
|
||||
Reference in New Issue
Block a user