This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/files.js
2026-03-14 16:46:16 +00:00

858 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// ==========================================
// Chat Switchboard Files
// ==========================================
// File upload, smart paste, drag-and-drop, extraction
// polling, staged lifecycle, message rendering, lightbox,
// and admin storage panel.
//
// Depends on: API, App, UI (toast, getModelValue, renderChatList)
// Consumed by: chat.js (send flow), ui-core.js (message rendering),
// ui-admin.js (storage tab)
// ── Constants ───────────────────────────────
const FILE_POLL_INTERVAL = 2000; // ms between extraction status checks
const FILE_MAX_PER_MSG = 5; // mirrors backend defaultMaxFilesPerMsg
// MIME categories for capability-aware filtering
const IMAGE_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
]);
// ── State ───────────────────────────────────
// Staged files waiting to be sent with the next message.
// Each entry:
// {
// localId: string — crypto.randomUUID(), stable key for DOM
// serverId: string? — file UUID from backend (null while uploading)
// filename: string
// contentType: string
// sizeBytes: number
// previewUrl: string? — object URL for image preview (revoked on remove)
// status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error'
// uploadProgress: number — 0100 (only meaningful during 'uploading')
// extractionStatus: string? — mirrors backend metadata.extraction_status
// queuePosition: number? — extraction queue position (0 = processing)
// error: string? — error message if status is 'error' or 'failed'
// }
// App.stagedFiles is initialized in app.js.
// Polling timers are tracked here for cleanup.
const _pollTimers = new Map(); // localId → intervalId
// ── Upload ──────────────────────────────────
/**
* Stage a file for upload. Creates the staged entry, uploads to backend,
* and starts extraction polling.
*
* If no current chat exists, creates one first (same pattern as sendMessage).
*
* @param {File} file — File object from input, paste, or drop
* @returns {Promise<void>}
*/
async function stageFile(file) {
if (!App.storageConfigured) return;
// Enforce per-message limit
if (App.stagedFiles.length >= FILE_MAX_PER_MSG) {
UI.toast(`Maximum ${FILE_MAX_PER_MSG} files per message`, 'warning');
return;
}
// Build staged entry
const staged = {
localId: crypto.randomUUID(),
serverId: null,
filename: file.name,
contentType: file.type || 'application/octet-stream',
sizeBytes: file.size,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
status: 'uploading',
uploadProgress: 0,
extractionStatus: null,
queuePosition: null,
error: null,
};
App.stagedFiles.push(staged);
_renderStrip();
_updateSendBlock();
// Ensure a channel exists (files are channel-scoped)
if (!App.activeId) {
try {
const title = file.name.slice(0, 50);
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = {
id: resp.id, title: resp.title, type: 'direct',
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
};
App.chats.unshift(chat);
App.setActive(chat.id, 'direct');
UI.renderChatList();
} catch (e) {
staged.status = 'error';
staged.error = 'Failed to create chat: ' + e.message;
_renderStrip();
_updateSendBlock();
return;
}
}
// Upload
try {
const result = await API.uploadFile(App.activeId, file);
staged.serverId = result.id;
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
staged.extractionStatus = result.metadata?.extraction_status || null;
staged.queuePosition = result.metadata?.queue_position ?? null;
// Start polling if extraction is pending/processing
if (staged.status === 'queued' || staged.status === 'processing') {
_startPolling(staged);
}
} catch (e) {
staged.status = 'error';
staged.error = e.message;
console.error('File upload failed:', e);
}
_renderStrip();
_updateSendBlock();
}
/**
* Remove a staged file by localId.
* Revokes object URL, stops polling, and optionally deletes from backend.
*/
async function unstageFile(localId) {
const idx = App.stagedFiles.findIndex(a => a.localId === localId);
if (idx === -1) return;
const staged = App.stagedFiles[idx];
// Clean up preview blob
if (staged.previewUrl) {
URL.revokeObjectURL(staged.previewUrl);
}
// Stop polling
_stopPolling(localId);
// Delete from backend if it was uploaded
if (staged.serverId) {
try {
await API.deleteFile(staged.serverId);
} catch (e) {
console.warn('Failed to delete staged file:', e.message);
}
}
App.stagedFiles.splice(idx, 1);
_renderStrip();
_updateSendBlock();
}
/**
* Clear all staged files (chat switch, cancel, etc.)
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
*/
function clearStaged() {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
// ── Send Integration ────────────────────────
/**
* Returns file IDs (server-side UUIDs) for the current staged set.
* Only includes successfully uploaded files.
*/
function getStagedFileIds() {
return App.stagedFiles
.filter(a => a.serverId)
.map(a => a.serverId);
}
/**
* Whether the send button should be blocked due to in-flight uploads.
* Per design: blocked ONLY while any file is actively uploading (bytes in flight).
* Queued/processing/failed extraction does NOT block send.
*/
function isSendBlocked() {
return App.stagedFiles.some(a => a.status === 'uploading');
}
/**
* Whether there are any staged files (for UI visibility).
*/
function hasStagedFiles() {
return App.stagedFiles.length > 0;
}
/**
* Post-send cleanup: detach staged list without deleting from backend
* (files are now linked to the sent message).
*/
function consumeStaged() {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
// ── Extraction Polling ──────────────────────
function _startPolling(staged) {
if (_pollTimers.has(staged.localId)) return;
const timer = setInterval(async () => {
if (!staged.serverId) return;
try {
const data = await API.getFile(staged.serverId);
const meta = data.metadata || {};
staged.extractionStatus = meta.extraction_status || null;
staged.queuePosition = meta.queue_position ?? null;
staged.status = _mapExtractionStatus(meta.extraction_status);
if (meta.extraction_error) {
staged.error = meta.extraction_error;
}
_renderStrip();
// Stop polling on terminal state
if (staged.status === 'complete' || staged.status === 'failed') {
_stopPolling(staged.localId);
}
} catch (e) {
console.warn('Extraction poll failed for', staged.localId, e.message);
// Don't stop polling on transient errors — backend might be restarting
}
}, FILE_POLL_INTERVAL);
_pollTimers.set(staged.localId, timer);
}
function _stopPolling(localId) {
const timer = _pollTimers.get(localId);
if (timer) {
clearInterval(timer);
_pollTimers.delete(localId);
}
}
function _stopAllPolling() {
for (const [id, timer] of _pollTimers) {
clearInterval(timer);
}
_pollTimers.clear();
}
// ── Status Helpers ──────────────────────────
/**
* Map backend extraction_status to frontend staged status.
* Images have no extraction — they go straight to 'complete'.
*/
function _mapExtractionStatus(backendStatus) {
switch (backendStatus) {
case 'pending': return 'queued';
case 'processing': return 'processing';
case 'complete': return 'complete';
case 'failed': return 'failed';
case 'unavailable': return 'complete'; // no extraction needed (e.g., images)
default: return 'queued';
}
}
/**
* Human-readable status label for a staged file.
*/
function fileStatusLabel(staged) {
switch (staged.status) {
case 'uploading':
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
case 'queued':
if (staged.queuePosition != null && staged.queuePosition > 0) {
return `Queued (${staged.queuePosition} ahead)`;
}
return 'Queued';
case 'processing': return 'Extracting…';
case 'complete': return '✓ Ready';
case 'failed': return '⚠ Extraction failed';
case 'error': return '✕ ' + (staged.error || 'Upload failed');
default: return '';
}
}
/**
* Whether the given content type is an image (for vision gating).
*/
function isImageFile(contentType) {
return IMAGE_TYPES.has(contentType);
}
/**
* Format file size for display.
*/
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ── Strip Rendering ─────────────────────────
function _renderStrip() {
const strip = document.getElementById('fileStrip');
if (!strip) return;
if (App.stagedFiles.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = App.stagedFiles.map(a => {
const icon = isImageFile(a.contentType) ? '🖼' : '📄';
const name = _truncFilename(a.filename, 20);
const size = formatFileSize(a.sizeBytes);
const label = fileStatusLabel(a);
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
a.status === 'complete' ? 'att-ready' : 'att-pending';
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
<span class="att-info">
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
</span>
<button class="att-remove" data-action="unstageFile" data-args='${JSON.stringify([a.localId])}'" title="Remove">✕</button>
</div>`;
}).join('');
}
function _truncFilename(name, max) {
if (name.length <= max) return name;
const ext = name.lastIndexOf('.');
if (ext > 0 && name.length - ext <= 6) {
// Preserve extension: "very-long-name...pdf"
const suffix = name.slice(ext);
return name.slice(0, max - suffix.length - 1) + '…' + suffix;
}
return name.slice(0, max - 1) + '…';
}
// ── Channel File Loading ──────────────
/**
* Fetch all files for a channel, build messageId → [att] map.
* Called after loading messages in selectChat / reloadActivePath.
* Non-blocking: failures just leave the map empty (files are optional UX).
*/
async function loadChannelFiles(channelId) {
App.channelFiles = {};
if (!channelId || !App.storageConfigured) return;
try {
const data = await API.listChannelFiles(channelId);
const list = data.files || data || [];
for (const att of list) {
if (!att.message_id) continue;
if (!App.channelFiles[att.message_id]) {
App.channelFiles[att.message_id] = [];
}
App.channelFiles[att.message_id].push(att);
}
} catch (e) {
console.debug('File load skipped:', e.message);
}
}
// ── Message File Rendering ────────────
/**
* Returns HTML string for files associated with a message.
* Called from ui-core.js _messageHTML(). Returns '' if none.
*
* Images use data-att-id; actual src is loaded post-render with auth
* via loadAuthImages(). Doc downloads use onclick handlers.
*/
function renderMessageFiles(msgId) {
if (!msgId) return '';
const atts = App.channelFiles[msgId];
if (!atts || atts.length === 0) return '';
// Check current model vision capability for hint
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const hasVision = !!modelInfo?.capabilities?.vision;
const items = atts.map(att => {
const isImage = isImageFile(att.content_type);
if (isImage) {
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
const w = Math.min(dims?.width || 280, 320);
const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: '';
return `<div class="msg-att msg-att-image" data-action="openLightbox" data-args='${JSON.stringify([esc(att.id)])}'">
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
</div>`;
}
// Document chip
const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" data-action="downloadFile" data-args='${JSON.stringify([esc(att.id), esc(att.filename)])}' data-prevent-default">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${esc(att.filename)}</span>
<span class="att-doc-size">${size}</span>
</span>
<span class="att-doc-dl">⬇</span>
</a>`;
});
return `<div class="msg-files">${items.join('')}</div>`;
}
function _docIcon(contentType) {
if (contentType === 'application/pdf') return '📕';
if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊';
if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙';
if (contentType?.includes('word') || contentType?.includes('document')) return '📄';
if (contentType?.startsWith('text/')) return '📝';
return '📎';
}
// ── Image Lightbox ──────────────────────────
function openLightbox(fileId) {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay || !img) return;
img.src = '';
img.alt = 'Loading…';
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
// Fetch full-size image with auth
API.downloadFileBlob(fileId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => { img.alt = 'Failed to load image'; });
}
function closeLightbox() {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay) return;
overlay.classList.remove('active');
document.body.style.overflow = '';
// Revoke previous blob URL to free memory
if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src);
if (img) { img.src = ''; img.alt = ''; }
}
// ── Auth Image Loading ──────────────────────
/**
* Post-render pass: finds all <img data-att-id="..."> in the given
* container and fetches them with auth headers, setting blob URLs.
* Called after renderMessages and reloadActivePath.
*/
function loadAuthImages(container) {
if (!container) container = document.getElementById('chatMessages');
if (!container) return;
const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])');
for (const img of imgs) {
const attId = img.dataset.attId;
if (!attId) continue;
img.dataset.loaded = '1';
API.downloadFileBlob(attId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => {
img.alt = '⚠ Load failed';
img.classList.add('att-load-error');
});
}
}
// ── Document Download ───────────────────────
/**
* Download a file via auth-aware fetch.
* Creates a temporary <a> element to trigger the browser download dialog.
*/
async function downloadFile(fileId, filename) {
try {
const blob = await API.downloadFileBlob(fileId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
UI.toast('Download failed: ' + e.message, 'error');
}
}
// ── Admin Storage Panel ─────────────────────
async function loadAdminStorage() {
const el = document.getElementById('adminStorageContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const [status, orphans] = await Promise.all([
API.adminGetStorageStatus().catch(() => null),
API.adminGetOrphanCount().catch(() => null),
]);
let html = '';
// Status card
if (status) {
const healthClass = status.healthy ? 'badge-success' : 'badge-danger';
const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy';
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Backend</span>
<span class="admin-storage-value">${esc(status.backend || 'none')}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Status</span>
<span class="badge ${healthClass}">${healthLabel}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Files</span>
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Size</span>
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
</div>
${status.path ? `<div class="admin-storage-item">
<span class="admin-storage-label">Path</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.path)}</span>
</div>` : ''}
${status.endpoint ? `<div class="admin-storage-item">
<span class="admin-storage-label">Endpoint</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.endpoint)}</span>
</div>` : ''}
${status.bucket ? `<div class="admin-storage-item">
<span class="admin-storage-label">Bucket</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.bucket)}</span>
</div>` : ''}
</div>
</div>`;
} else {
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
</div>`;
}
// Orphan cleanup card
if (orphans) {
const count = orphans.count ?? 0;
const bytes = orphans.total_bytes ?? 0;
html += `<div class="admin-storage-card">
<h4>Orphan Files</h4>
<p class="admin-storage-desc">Files uploaded but never linked to a message for &gt;24 hours.</p>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Orphaned</span>
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Reclaimable</span>
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
</div>
</div>
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
</div>`;
}
el.innerHTML = html;
// Wire cleanup button
document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Cleaning…';
try {
const result = await API.adminRunStorageCleanup();
UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success');
await loadAdminStorage(); // refresh counts
} catch (err) {
UI.toast('Cleanup failed: ' + err.message, 'error');
btn.disabled = false;
btn.textContent = 'Run Cleanup Now';
}
});
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${esc(e.message)}</div>`;
}
}
// ── Constants (Paste) ───────────────────────
// Threshold for auto-attaching pasted text as a file.
// Synced from backend via PublicSettings (storage.paste_to_file_chars).
// Falls back to 2000 if not yet loaded.
function _pasteToFileChars() {
return App.pasteToFileChars || 2000;
}
// Accept string for file picker — mirrors backend allowedMIMETypes.
// Backend is authoritative (rejects disallowed types server-side),
// this just gives the OS file dialog a better filter.
const FILE_ACCEPT = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'text/plain', 'text/markdown', 'text/csv',
'.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf',
].join(',');
// ── MIME → Extension ────────────────────────
const _mimeToExt = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg',
'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md',
'text/csv': 'csv', 'text/html': 'html',
};
function _extFromMime(mime) {
return _mimeToExt[mime] || mime.split('/').pop() || 'bin';
}
// ── Send Button Blocking ────────────────────
function _updateSendBlock() {
const sendBtn = document.getElementById('sendBtn');
if (!sendBtn) return;
// Merge with existing generation check — don't override if generating
if (App.isGenerating) return;
if (isSendBlocked()) {
sendBtn.disabled = true;
sendBtn.title = 'Upload in progress…';
} else {
sendBtn.disabled = false;
sendBtn.title = 'Send';
}
}
// ── Init + Listeners ────────────────────────
/**
* Called from app.js startApp(). Reads storage_configured from boot
* payload and sets up file UI visibility.
*/
function initFiles() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
attachBtn.style.display = App.storageConfigured ? '' : 'none';
}
if (fileInput) {
fileInput.accept = FILE_ACCEPT;
}
console.log(`📎 Files: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
}
/**
* Called from app.js initListeners(). Wires up all file input
* handlers: button, file picker, paste, and drag-and-drop.
*/
function _initFileListeners() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const chatArea = document.getElementById('chatMessages');
const lightbox = document.getElementById('lightbox');
// ── 📎 Button → File Picker ─────────────
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => {
if (!App.storageConfigured) return;
fileInput.click();
});
fileInput.addEventListener('change', () => {
const files = fileInput.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
fileInput.value = ''; // reset so same file can be re-selected
});
}
// ── Smart Paste ─────────────────────────
// Attach to the active input element (CM6 contentDOM or textarea)
const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
? ChatInput.getDom()
: document.getElementById('messageInput');
if (pasteTarget) {
pasteTarget.addEventListener('paste', (e) => {
if (!App.storageConfigured) return; // fall through to normal paste
const items = [...(e.clipboardData?.items || [])];
// Binary detection: images/files from clipboard
const binaryItems = items.filter(i => i.kind === 'file');
if (binaryItems.length > 0) {
e.preventDefault();
for (const item of binaryItems) {
const file = item.getAsFile();
if (!file) continue;
// Generate UUID filename — original clipboard items have no useful name
const ext = _extFromMime(file.type);
const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type });
stageFile(renamed);
}
return;
}
// Large text detection — auto-attach as .txt file
const _ptfChars = _pasteToFileChars();
if (_ptfChars > 0) {
const text = e.clipboardData?.getData('text/plain');
if (text && text.length > _ptfChars) {
e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
stageFile(file);
return;
}
}
// Below threshold or no text: normal paste (no preventDefault)
});
}
// ── Drag-and-Drop ───────────────────────
if (chatArea) {
// Track drag enter/leave depth to avoid flicker from child elements
let dragDepth = 0;
chatArea.addEventListener('dragenter', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault();
dragDepth++;
if (dragDepth === 1) chatArea.classList.add('drag-over');
});
chatArea.addEventListener('dragover', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault(); // required to allow drop
e.dataTransfer.dropEffect = 'copy';
});
chatArea.addEventListener('dragleave', (e) => {
if (!App.storageConfigured) return;
dragDepth--;
if (dragDepth <= 0) {
dragDepth = 0;
chatArea.classList.remove('drag-over');
}
});
chatArea.addEventListener('drop', (e) => {
if (!App.storageConfigured) return;
e.preventDefault();
dragDepth = 0;
chatArea.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
});
}
// ── Lightbox ────────────────────────────
if (lightbox) {
// Click overlay (not the image) to close
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox || e.target.classList.contains('lightbox-close')) {
closeLightbox();
}
});
}
}
/**
* Check if a drag event contains files (not just text selection drag).
*/
function _hasDragFiles(e) {
if (!e.dataTransfer?.types) return false;
return e.dataTransfer.types.includes('Files');
}
// ── Exports ─────────────────────────────────
sb.register('_initFileListeners', _initFileListeners);
sb.register('_pollTimers', _pollTimers);
sb.register('_startPolling', _startPolling);
sb.register('_stopPolling', _stopPolling);
sb.register('clearStaged', clearStaged);
sb.register('closeLightbox', closeLightbox);
sb.register('consumeStaged', consumeStaged);
sb.register('getStagedFileIds', getStagedFileIds);
sb.register('hasStagedFiles', hasStagedFiles);
sb.register('initFiles', initFiles);
sb.register('isSendBlocked', isSendBlocked);
sb.register('loadAdminStorage', loadAdminStorage);
sb.register('loadAuthImages', loadAuthImages);
sb.register('loadChannelFiles', loadChannelFiles);
sb.register('renderMessageFiles', renderMessageFiles);
sb.register('unstageFile', unstageFile);
sb.register('openLightbox', openLightbox);
sb.register('downloadFile', downloadFile);