// app.js — bootstrap, input handling, sidebar, attach, paste import { send, on } from './ws.js'; import { isAuthenticated, logout, getTempId, getClientId } from './auth.js'; import { createNewSession, switchSession, currentSessionId, onSessionChange } from './sessions.js'; import { submitMessage, renderSession, setActiveSession, getIsStreaming } from './chat.js'; import { openAuthModal, closeModal, openPasteEditor } from './modals.js'; import { openSettings, applyTheme } from './settings.js'; import { showNotification, autoResize, escHtml } from './ui.js'; // ── Sidebar ─────────────────────────────────────────────────────────────── const sidebar = document.getElementById('sidebar'); const toggleBtn = document.getElementById('toggle-sidebar-btn'); function expandSidebar() { sidebar?.classList.remove('collapsed'); sidebar?.classList.add('expanded'); } function collapseSidebar(){ sidebar?.classList.remove('expanded'); sidebar?.classList.add('collapsed'); } function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); } toggleBtn?.addEventListener('click', toggleSidebar); if (window.innerWidth >= 768) expandSidebar(); // ── New chat ────────────────────────────────────────────────────────────── document.getElementById('new-chat-btn')?.addEventListener('click', () => { createNewSession(); if (window.innerWidth < 768) collapseSidebar(); }); // ── Session switching ───────────────────────────────────────────────────── onSessionChange((event, data) => { if (event === 'switched') { setActiveSession(data); if (!data) { document.getElementById('welcome-view')?.classList.remove('hidden'); document.getElementById('chat-view')?.classList.add('hidden'); document.getElementById('bottom-input-bar')?.classList.add('hidden'); } } if (event === 'data') { if (data.id === currentSessionId) renderSession(data); } if (event === 'created') { switchSession(data.id); if (window.innerWidth < 768) collapseSidebar(); } }); // ── Center input (welcome view) ─────────────────────────────────────────── const centerInput = document.getElementById('center-input'); const centerSendBtn = document.getElementById('center-send-btn'); centerInput?.addEventListener('input', () => autoResize(centerInput, 6)); centerInput?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); triggerCenterSend(); } }); centerSendBtn?.addEventListener('click', triggerCenterSend); // Center tool buttons document.querySelectorAll('#center-tool-search, #center-tool-image, #center-tool-video, #center-tool-audio') .forEach(btn => btn.addEventListener('click', () => btn.classList.toggle('active'))); function triggerCenterSend() { const text = centerInput?.value.trim(); const attachments = pendingAttachments.splice(0); if (!text && attachments.length === 0) return; if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); } clearFilePreviewRow(); if (!currentSessionId) { // Create session first, then send const pendingText = text, pendingAttach = attachments; const unsub = onSessionChange((ev, s) => { if (ev !== 'created') return; unsub(); doSend(pendingText, pendingAttach); }); createNewSession(); } else { doSend(text, attachments); } } // ── Bottom input (active chat) ──────────────────────────────────────────── const bottomInput = document.getElementById('bottom-input'); const bottomSendBtn = document.getElementById('bottom-send-btn'); bottomInput?.addEventListener('input', () => autoResize(bottomInput, 6)); bottomInput?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); triggerBottomSend(); } }); bottomSendBtn?.addEventListener('click', () => { if (getIsStreaming()) { send({ type: 'chat:stop' }); return; } triggerBottomSend(); }); function triggerBottomSend() { const text = bottomInput?.value.trim(); const attachments = pendingAttachments.splice(0); if (!text && attachments.length === 0) return; if (bottomInput) { bottomInput.value = ''; autoResize(bottomInput, 6); } clearFilePreviewRow(); doSend(text || '', attachments); } document.querySelectorAll('.tool-btn-sm').forEach(btn => btn.addEventListener('click', () => btn.classList.toggle('active'))); // ── Core send ───────────────────────────────────────────────────────────── function doSend(text, attachments = []) { submitMessage(text, attachments); } // ── Attachments ─────────────────────────────────────────────────────────── let pendingAttachments = []; const LARGE_PASTE_THRESHOLD = 10000; function openAttachMenu(e, triggerEl) { e.preventDefault(); e.stopPropagation(); const menu = document.getElementById('attach-context-menu'); if (!menu) return; menu.innerHTML = ''; for (const item of [ { label: '📄 Upload file', onClick: () => document.getElementById('file-input')?.click() }, { label: '🖼️ Upload image', onClick: () => document.getElementById('image-input')?.click() }, ]) { const el = document.createElement('div'); el.className = 'context-item'; el.textContent = item.label; el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); }); menu.appendChild(el); } menu.classList.remove('hidden'); // Position above the trigger const rect = triggerEl.getBoundingClientRect(); const mh = menu.getBoundingClientRect().height || 80; menu.style.left = `${Math.max(8, rect.left)}px`; menu.style.top = `${rect.top - mh - 8}px`; setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0); } document.getElementById('center-attach-btn')?.addEventListener('click', e => openAttachMenu(e, document.getElementById('center-attach-btn'))); document.getElementById('bottom-attach-btn')?.addEventListener('click', e => openAttachMenu(e, document.getElementById('bottom-attach-btn'))); // File input handlers document.getElementById('file-input')?.addEventListener('change', async function() { for (const file of this.files) { const text = await file.text(); pendingAttachments.push({ type: 'text', name: file.name, content: text }); } this.value = ''; renderFilePreviewRow(); }); document.getElementById('image-input')?.addEventListener('change', async function() { for (const file of this.files) await addImageFile(file); this.value = ''; }); async function addImageFile(file) { const dataUrl = await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = () => res(reader.result); reader.onerror = rej; reader.readAsDataURL(file); }); const comma = dataUrl.indexOf(','); const mimeType = dataUrl.slice(5, dataUrl.indexOf(';')); const base64 = dataUrl.slice(comma + 1); pendingAttachments.push({ type: 'image', name: file.name, base64, mimeType }); renderFilePreviewRow(); } function renderFilePreviewRow() { const row = document.getElementById('file-preview-row'); if (!row) return; row.innerHTML = ''; pendingAttachments.forEach((a, i) => { const wrap = document.createElement('div'); wrap.style.cssText = 'position:relative;display:inline-flex;align-items:center;'; if (a.type === 'image') { const img = document.createElement('img'); img.src = `data:${a.mimeType};base64,${a.base64}`; img.style.cssText = 'width:48px;height:48px;object-fit:cover;border-radius:6px;display:block;'; wrap.appendChild(img); } else { const chip = document.createElement('div'); chip.className = 'pasted-chip'; const icon = document.createElement('span'); icon.textContent = '📄'; const name = document.createElement('span'); name.textContent = a.name; chip.appendChild(icon); chip.appendChild(name); chip.addEventListener('click', () => openPasteEditor(a.content, nc => { pendingAttachments[i].content = nc; })); wrap.appendChild(chip); } const rm = document.createElement('button'); rm.textContent = '×'; rm.style.cssText = 'position:absolute;top:-5px;right:-5px;width:18px;height:18px;border-radius:50%;' + 'background:var(--bg-raised);border:1px solid var(--border-bright);cursor:pointer;' + 'display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--text);z-index:1;'; rm.addEventListener('click', e => { e.stopPropagation(); pendingAttachments.splice(i, 1); renderFilePreviewRow(); }); wrap.appendChild(rm); row.appendChild(wrap); }); } function clearFilePreviewRow() { pendingAttachments = []; const row = document.getElementById('file-preview-row'); if (row) row.innerHTML = ''; } // ── Paste & drag-drop ───────────────────────────────────────────────────── function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []); // Image in clipboard const imgItem = items.find(i => i.kind === 'file' && i.type.startsWith('image/')); if (imgItem) { e.preventDefault(); addImageFile(imgItem.getAsFile()); return; } // Large text const textItem = items.find(i => i.kind === 'string' && i.type === 'text/plain'); if (textItem) { textItem.getAsString(text => { if (text.length > LARGE_PASTE_THRESHOLD) { e.preventDefault(); pendingAttachments.push({ type: 'text', name: `pasted-${Date.now()}.txt`, content: text }); renderFilePreviewRow(); } }); } } [centerInput, bottomInput].forEach(input => { input?.addEventListener('paste', handlePaste); input?.addEventListener('dragover', e => e.preventDefault()); input?.addEventListener('drop', async e => { e.preventDefault(); for (const file of e.dataTransfer.files) if (file.type.startsWith('image/')) await addImageFile(file); }); }); // ── Auth/settings buttons ───────────────────────────────────────────────── document.getElementById('signin-btn')?.addEventListener('click', () => openAuthModal('signin')); document.getElementById('settings-btn')?.addEventListener('click', () => openSettings('chat')); document.getElementById('settings-btn-guest')?.addEventListener('click', () => openSettings('chat')); document.getElementById('user-profile-btn')?.addEventListener('click', e => { const btn = e.currentTarget; const rect = btn.getBoundingClientRect(); const menu = document.getElementById('user-context-menu'); if (!menu) return; const menuItems = [ { label: '⚙️ Settings', onClick: () => openSettings('chat') }, { label: '👤 Account', onClick: () => openSettings('account') }, { label: '💳 Billing Portal', onClick: () => window.open('https://sharktide-lightning.hf.space/portal', '_blank') }, { sep: true }, { label: '🗑️ Clear All Chats', danger: true, onClick: () => { if (confirm('Delete all chats? This cannot be undone.')) send({ type: 'sessions:deleteAll' }); }}, { sep: true }, { label: '🚪 Sign Out', danger: true, onClick: () => logout() }, ]; menu.innerHTML = ''; for (const item of menuItems) { if (item.sep) { const s = document.createElement('div'); s.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; menu.appendChild(s); continue; } const el = document.createElement('div'); el.className = 'context-item' + (item.danger ? ' danger' : ''); el.textContent = item.label; el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); }); menu.appendChild(el); } menu.classList.remove('hidden'); const mw = 200; let left = rect.left, top = rect.top; const mh = menu.scrollHeight || 200; top = rect.top - mh - 8; if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8; menu.style.left = `${Math.max(8, left)}px`; menu.style.top = `${Math.max(8, top)}px`; setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0); }); // ── Share import from URL ?share=token ──────────────────────────────────── function checkShareParam() { const params = new URLSearchParams(location.search); const token = params.get('share'); if (!token) return; const banner = document.getElementById('share-import-banner'); const bannerText= document.getElementById('share-banner-text'); const importBtn = document.getElementById('share-import-btn'); const dismissBtn= document.getElementById('share-dismiss-btn'); fetch(`/api/share/${encodeURIComponent(token)}`) .then(r => r.ok ? r.json() : null) .then(data => { if (!data) return; if (bannerText) bannerText.textContent = `Import shared chat: "${data.name}"?`; banner?.classList.remove('hidden'); }) .catch(() => {}); importBtn?.addEventListener('click', () => { if (!isAuthenticated()) { openAuthModal('signin'); return; } send({ type: 'sessions:import', token }); banner?.classList.add('hidden'); history.replaceState({}, '', '/'); }); dismissBtn?.addEventListener('click', () => { banner?.classList.add('hidden'); history.replaceState({}, '', '/'); }); } // ── Connection notifications ────────────────────────────────────────────── let wasDisconnected = false; on('ws:disconnected', () => { wasDisconnected = true; showNotification({ type: 'warning', message: 'Connection lost — reconnecting…', duration: 3000 }); }); on('ws:connected', () => { if (wasDisconnected) showNotification({ type: 'success', message: 'Reconnected', duration: 2000 }); wasDisconnected = false; }); // ── Init ────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { checkShareParam(); applyTheme('dark'); // default until auth resolves });