Download public/js/app.js from sharktide/chat: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/spaces/sharktide/chat/resolve/4768abbbdecf41e23cfb355c840cc44d0e952929/public/js/app.js
- Command line
-
hf download hf://spaces/sharktide/chat@4768abbbdecf41e23cfb355c840cc44d0e952929/public/js/app.js
-
curl -L -o app.js https://huggingface.co/spaces/sharktide/chat/resolve/4768abbbdecf41e23cfb355c840cc44d0e952929/public/js/app.js
18.8 kB
| // 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 | |
| }); |