// app.js — bootstrap, input handling, sidebar, attach, paste import { send, on } from './ws.js'; import { isAuthenticated, logout, getTempId, getClientId } from './auth.js'; import { createNewSession, showWelcomeScreen, 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'; // ── TOS banner — push content up so input isn't obscured ───────────────── (function fixTosBanner() { const banner = document.getElementById('tos-banner'); if (!banner) return; const applyPadding = () => { const isMobile = window.innerWidth <= 768; if (isMobile) { // On mobile: TOS is fixed at bottom, bottom-input-bar padding is set via CSS // Just ensure welcome-view accounts for TOS height const h = banner.offsetHeight || 36; const welcomeView = document.getElementById('welcome-view'); if (welcomeView) welcomeView.style.paddingBottom = `${h + 8}px`; } else { const h = banner.offsetHeight || 42; document.getElementById('bottom-input-bar')?.style.setProperty('padding-bottom', `${h + 8}px`); const welcomeView = document.getElementById('welcome-view'); if (welcomeView) welcomeView.style.paddingBottom = `${h + 16}px`; } }; requestAnimationFrame(() => { applyPadding(); }); window.addEventListener('resize', applyPadding); })(); // ── 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); // ── Mobile top bar + sidebar logic ─────────────────────────────────────── (function setupMobile() { // Create backdrop const backdrop = document.createElement('div'); backdrop.id = 'sidebar-backdrop'; backdrop.className = 'sidebar-backdrop'; document.body.appendChild(backdrop); function openSidebar() { sidebar?.classList.remove('collapsed'); sidebar?.classList.add('expanded'); backdrop.classList.add('visible'); } function closeSidebar() { sidebar?.classList.remove('expanded'); sidebar?.classList.add('collapsed'); backdrop.classList.remove('visible'); } backdrop.addEventListener('click', closeSidebar); // Wire mobile top bar buttons document.getElementById('mobile-sidebar-btn')?.addEventListener('click', () => { sidebar?.classList.contains('expanded') ? closeSidebar() : openSidebar(); }); document.getElementById('mobile-newchat-btn')?.addEventListener('click', () => { showWelcomeScreen(); const ci = document.getElementById('center-input'); if (ci) { ci.value = ''; autoResize(ci, 6); } const box = document.getElementById('chat-messages'); if (box) box.innerHTML = ''; }); // Auto-close drawer when a session is switched on mobile onSessionChange((event) => { if ((event === 'switched' || event === 'created') && window.innerWidth <= 768) { closeSidebar(); } }); // ── Hide/show top bar on scroll ───────────────────────────────────────── let lastScrollY = 0; let ticking = false; const topBar = document.getElementById('mobile-top-bar'); function handleChatScroll(e) { if (window.innerWidth > 768) return; const el = e.target; const currentY = el.scrollTop; if (!ticking) { requestAnimationFrame(() => { if (currentY > lastScrollY && currentY > 60) { // Scrolling down — hide bar topBar?.classList.add('hidden-bar'); } else { // Scrolling up — show bar topBar?.classList.remove('hidden-bar'); } lastScrollY = currentY <= 0 ? 0 : currentY; ticking = false; }); ticking = true; } } // Attach to chat-view scroll document.getElementById('chat-view')?.addEventListener('scroll', handleChatScroll, { passive: true }); // Always show top bar when on welcome screen onSessionChange((event, data) => { if (event === 'switched' && !data) { topBar?.classList.remove('hidden-bar'); lastScrollY = 0; } }); })(); // ── New chat — just show the welcome screen; don't create a session yet ─── document.getElementById('new-chat-btn')?.addEventListener('click', () => { showWelcomeScreen(); if (window.innerWidth < 768) { /* handled by setupMobile onSessionChange */ } const ci = document.getElementById('center-input'); if (ci) { ci.value = ''; autoResize(ci, 6); } const box = document.getElementById('chat-messages'); if (box) box.innerHTML = ''; }); // ── Session switching ───────────────────────────────────────────────────── onSessionChange((event, data) => { if (event === 'switched') { setActiveSession(data); if (!data) { // Clear chat when going to welcome const box = document.getElementById('chat-messages'); if (box) box.innerHTML = ''; 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') { setActiveSession(data.id); switchSession(data.id); } }); // ── 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(); const pendingText = text, pendingAttach = attachments; const unsub = onSessionChange((ev, s) => { if (ev !== 'switched' || !s) return; unsub(); doSend(pendingText, pendingAttach); }); createNewSession(); } // ── 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'); 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'))); 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 buildAttachmentItem(a, i) { const wrap = document.createElement('div'); wrap.className = 'attach-preview-item'; if (a.type === 'image') { const img = document.createElement('img'); img.src = `data:${a.mimeType};base64,${a.base64}`; img.alt = a.name; 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.className = 'attach-preview-remove'; rm.textContent = '×'; rm.addEventListener('click', e => { e.stopPropagation(); pendingAttachments.splice(i, 1); renderFilePreviewRow(); }); wrap.appendChild(rm); return wrap; } function renderFilePreviewRow() { const centerRow = document.getElementById('center-file-preview-row'); const bottomRow = document.getElementById('file-preview-row'); [centerRow, bottomRow].forEach(row => { if (!row) return; row.innerHTML = ''; if (pendingAttachments.length === 0) { row.style.display = 'none'; return; } row.style.display = 'flex'; pendingAttachments.forEach((a, i) => row.appendChild(buildAttachmentItem(a, i))); }); } function clearFilePreviewRow() { pendingAttachments = []; ['center-file-preview-row', 'file-preview-row'].forEach(id => { const row = document.getElementById(id); if (row) { row.innerHTML = ''; row.style.display = 'none'; } }); } // ── Paste & drag-drop ───────────────────────────────────────────────────── function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []); const imgItem = items.find(i => i.kind === 'file' && i.type.startsWith('image/')); if (imgItem) { e.preventDefault(); addImageFile(imgItem.getAsFile()); return; } 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('signin-icon-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 & reconnect handling ───────────────────────── 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 });