chat / public /js /app.js
incognitolm
Fix JS Folder
deb39b7
Raw
History Blame
15.1 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, 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
});