Odysseus v1.0
This commit is contained in:
372
static/js/init.js
Normal file
372
static/js/init.js
Normal file
@@ -0,0 +1,372 @@
|
||||
// Odysseus UI — Initialization Scripts
|
||||
// ES6 module — extracted from index.html inline scripts
|
||||
|
||||
import Storage from './storage.js';
|
||||
|
||||
function clearFreshComposerRestore() {
|
||||
const msgInput = document.getElementById('message');
|
||||
if (!msgInput) return;
|
||||
const hasSessionTarget = !!(window.location.hash || Storage.get('lastSessionId'));
|
||||
if (hasSessionTarget) return;
|
||||
if (msgInput.value) {
|
||||
msgInput.value = '';
|
||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
clearFreshComposerRestore();
|
||||
window.addEventListener('pageshow', clearFreshComposerRestore);
|
||||
|
||||
// SECURITY: defense-in-depth state wipe on user switch. If the authenticated
|
||||
// user is different from the one whose state is cached in this browser,
|
||||
// wipe localStorage + sessionStorage so the new account doesn't inherit
|
||||
// the previous user's last session id, last-used model, draft chat input,
|
||||
// or cached lists. The settings-tab Logout button already wipes on
|
||||
// explicit logout; this catches the cases where a different user signs
|
||||
// in without the previous one logging out cleanly.
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const liveUser = (data && data.username) || '';
|
||||
if (!liveUser) return;
|
||||
const KEY = 'odysseus-auth-user';
|
||||
const cachedUser = localStorage.getItem(KEY);
|
||||
if (cachedUser && cachedUser !== liveUser) {
|
||||
const _keepKeys = new Set(['odysseus-last-user', KEY]);
|
||||
const toRemove = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && !_keepKeys.has(k)) toRemove.push(k);
|
||||
}
|
||||
toRemove.forEach(k => localStorage.removeItem(k));
|
||||
sessionStorage.clear();
|
||||
clearFreshComposerRestore();
|
||||
}
|
||||
localStorage.setItem(KEY, liveUser);
|
||||
// Apply per-user privilege gates to the UI. The backend enforces these
|
||||
// independently — this is purely cosmetic / "don't dangle controls the
|
||||
// user can't actually use." Privileges come from /api/auth/status; admins
|
||||
// always get the full set so this is a no-op for them.
|
||||
try {
|
||||
const privs = (data && data.privileges) || {};
|
||||
const hideOn = (selector, allowed) => {
|
||||
if (allowed === undefined || allowed === true) return;
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
};
|
||||
// Document editor — overflow menu button + the docs panel rail/tool button.
|
||||
hideOn('#overflow-doc-btn, #tool-doc-btn', privs.can_use_documents);
|
||||
// Research — sidebar tool + the in-input deep-research toggle.
|
||||
hideOn('#tool-research-btn, #research-toggle-btn', privs.can_use_research);
|
||||
// Memory & skills (rail/tool button only — UI/API entry).
|
||||
hideOn('#tool-memory-btn', privs.can_manage_memory);
|
||||
// Agent mode toggle — force chat mode by hiding the Agent toggle button.
|
||||
if (privs.can_use_agent === false) {
|
||||
const _agent = document.getElementById('mode-agent-btn');
|
||||
const _chat = document.getElementById('mode-chat-btn');
|
||||
if (_agent) _agent.style.display = 'none';
|
||||
if (_chat) { _chat.classList.add('active'); _chat.click?.(); }
|
||||
}
|
||||
} catch (_) { /* DOM not ready or unexpected shape — UI gates are non-fatal */ }
|
||||
} catch (_) { /* anonymous / loopback mode — nothing to do */ }
|
||||
})();
|
||||
|
||||
/* Sidebar section default-collapsed setup. The click-to-toggle handlers
|
||||
themselves live in js/section-management.js — attaching them in BOTH
|
||||
places caused two toggles per click, which read as "clicks aren't doing
|
||||
anything" (even-count parity). Keep only the initial-state-apply here. */
|
||||
{
|
||||
const KEY = Storage.KEYS.SIDEBAR_COLLAPSED;
|
||||
const saved = Storage.getJSON(KEY, {});
|
||||
const _defaultCollapsed = { 'sessions-section': true };
|
||||
document.querySelectorAll('.sidebar .section').forEach((section) => {
|
||||
const id = section.id;
|
||||
if (!id) return;
|
||||
const shouldCollapse = (id in saved) ? saved[id] : !!_defaultCollapsed[id];
|
||||
if (shouldCollapse) section.classList.add('collapsed');
|
||||
});
|
||||
// Sessions-section notification dot: clear when the section becomes
|
||||
// expanded. Watch the class with MutationObserver so we don't need a
|
||||
// click handler (which would race the section-management one).
|
||||
const sessionsSection = document.getElementById('sessions-section');
|
||||
if (sessionsSection) {
|
||||
new MutationObserver(() => {
|
||||
if (!sessionsSection.classList.contains('collapsed')) {
|
||||
const dot = document.getElementById('chats-notif-dot');
|
||||
if (dot) dot.style.display = 'none';
|
||||
}
|
||||
}).observe(sessionsSection, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
}
|
||||
|
||||
/* Publish the icon rail's + wide sidebar's current widths as CSS vars so
|
||||
fullscreen panels can reserve space on the left for whichever is
|
||||
currently visible (the two are mutually exclusive — see
|
||||
sidebar-layout.js:57). Updates live as either resizes; toggles to 0
|
||||
when hidden so the fullscreen view reclaims the space. */
|
||||
{
|
||||
const rail = document.getElementById('icon-rail');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const root = document.documentElement;
|
||||
const _measure = (el) => {
|
||||
if (!el) return null;
|
||||
const cs = window.getComputedStyle(el);
|
||||
const hidden = cs.display === 'none' || cs.visibility === 'hidden';
|
||||
if (hidden) return 0;
|
||||
return Math.round(el.getBoundingClientRect().width);
|
||||
};
|
||||
const _sync = () => {
|
||||
// Icon rail width
|
||||
const rw = _measure(rail);
|
||||
if (rw === null) {
|
||||
root.style.removeProperty('--icon-rail-w');
|
||||
} else if (rw > 0) {
|
||||
root.style.setProperty('--icon-rail-w', rw + 'px');
|
||||
} else {
|
||||
// 0 from a visible-but-not-yet-laid-out rail: don't shadow the
|
||||
// CSS fallback; re-sync on the next frame instead.
|
||||
const cs = rail && window.getComputedStyle(rail);
|
||||
const hidden = !cs || cs.display === 'none' || cs.visibility === 'hidden';
|
||||
if (hidden) {
|
||||
root.style.setProperty('--icon-rail-w', '0px');
|
||||
} else {
|
||||
root.style.removeProperty('--icon-rail-w');
|
||||
requestAnimationFrame(_sync);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Sidebar width — `.sidebar.hidden` collapses to width: 0 so the
|
||||
// measurement is naturally 0 in the hidden state.
|
||||
const sw = _measure(sidebar);
|
||||
if (sw === null) {
|
||||
root.style.removeProperty('--sidebar-w');
|
||||
} else {
|
||||
root.style.setProperty('--sidebar-w', sw + 'px');
|
||||
}
|
||||
};
|
||||
_sync();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(_sync);
|
||||
if (rail) ro.observe(rail);
|
||||
if (sidebar) ro.observe(sidebar);
|
||||
}
|
||||
// Class flips (sidebar.hidden ↔ visible) don't trigger ResizeObserver
|
||||
// until layout settles a frame later; also watch the class attribute
|
||||
// so we re-sync immediately when the user toggles the hamburger.
|
||||
if (sidebar && typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(_sync).observe(sidebar, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
}
|
||||
if (rail && typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(_sync).observe(rail, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
}
|
||||
window.addEventListener('resize', _sync);
|
||||
}
|
||||
|
||||
/* ---- Resizable sidebar — drag edge to resize, collapse if small, drag rail edge to expand ---- */
|
||||
{
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const handle = document.getElementById('sidebar-resize-handle');
|
||||
const railHandle = document.getElementById('rail-resize-handle');
|
||||
const iconRail = document.getElementById('icon-rail');
|
||||
if (sidebar && handle) {
|
||||
|
||||
const STORAGE_KEY = Storage.KEYS.SIDEBAR_WIDTH;
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 700;
|
||||
const COLLAPSE_THRESHOLD = 150;
|
||||
|
||||
function getSavedWidth() {
|
||||
const w = parseInt(Storage.get(STORAGE_KEY, '340'), 10);
|
||||
return (w >= MIN_WIDTH && w <= MAX_WIDTH) ? w : 340;
|
||||
}
|
||||
|
||||
// Restore saved width
|
||||
const savedWidth = Storage.get(STORAGE_KEY);
|
||||
if (savedWidth) {
|
||||
const w = parseInt(savedWidth, 10);
|
||||
if (w >= MIN_WIDTH && w <= MAX_WIDTH) sidebar.style.width = w + 'px';
|
||||
}
|
||||
|
||||
let startX, startWidth, isRight, collapsed, expanding;
|
||||
|
||||
// --- Drag from sidebar edge to resize / collapse ---
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
expanding = false;
|
||||
isRight = sidebar.classList.contains('right-side');
|
||||
startX = e.clientX;
|
||||
startWidth = sidebar.getBoundingClientRect().width;
|
||||
collapsed = false;
|
||||
sidebar.classList.add('resizing');
|
||||
handle.classList.add('dragging');
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
});
|
||||
|
||||
// --- Drag from icon rail edge to expand sidebar ---
|
||||
if (railHandle) {
|
||||
railHandle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
expanding = true;
|
||||
isRight = sidebar.classList.contains('right-side') ||
|
||||
iconRail.classList.contains('right-side');
|
||||
startX = e.clientX;
|
||||
collapsed = false;
|
||||
|
||||
// Unhide sidebar at 0 width so we can grow it
|
||||
sidebar.classList.remove('hidden');
|
||||
sidebar.classList.add('resizing');
|
||||
sidebar.style.width = '0px';
|
||||
sidebar.style.opacity = '0.3';
|
||||
railHandle.classList.add('dragging');
|
||||
|
||||
document.addEventListener('mousemove', onExpandDrag);
|
||||
document.addEventListener('mouseup', stopExpandDrag);
|
||||
});
|
||||
}
|
||||
|
||||
function onDrag(e) {
|
||||
const delta = isRight ? (startX - e.clientX) : (e.clientX - startX);
|
||||
const rawWidth = startWidth + delta;
|
||||
|
||||
if (rawWidth < COLLAPSE_THRESHOLD) {
|
||||
sidebar.style.width = Math.max(0, rawWidth) + 'px';
|
||||
sidebar.style.opacity = Math.max(0.2, rawWidth / COLLAPSE_THRESHOLD);
|
||||
collapsed = true;
|
||||
} else {
|
||||
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, rawWidth));
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
sidebar.style.opacity = '';
|
||||
collapsed = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
sidebar.classList.remove('resizing');
|
||||
handle.classList.remove('dragging');
|
||||
sidebar.style.opacity = '';
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
|
||||
if (collapsed) {
|
||||
sidebar.style.width = '';
|
||||
sidebar.classList.add('hidden');
|
||||
if (typeof syncRailSide === 'function') syncRailSide();
|
||||
} else {
|
||||
const finalWidth = parseInt(sidebar.style.width, 10);
|
||||
if (finalWidth >= MIN_WIDTH) {
|
||||
Storage.set(STORAGE_KEY, String(finalWidth));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onExpandDrag(e) {
|
||||
const delta = isRight ? (startX - e.clientX) : (e.clientX - startX);
|
||||
const rawWidth = Math.max(0, delta);
|
||||
|
||||
if (rawWidth < COLLAPSE_THRESHOLD) {
|
||||
sidebar.style.width = rawWidth + 'px';
|
||||
sidebar.style.opacity = Math.max(0.3, rawWidth / COLLAPSE_THRESHOLD);
|
||||
collapsed = true;
|
||||
} else {
|
||||
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, rawWidth));
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
sidebar.style.opacity = '';
|
||||
collapsed = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopExpandDrag() {
|
||||
sidebar.classList.remove('resizing');
|
||||
sidebar.style.opacity = '';
|
||||
if (railHandle) railHandle.classList.remove('dragging');
|
||||
document.removeEventListener('mousemove', onExpandDrag);
|
||||
document.removeEventListener('mouseup', stopExpandDrag);
|
||||
|
||||
if (collapsed) {
|
||||
// Didn't drag far enough — snap back to icon rail
|
||||
sidebar.style.width = '';
|
||||
sidebar.classList.add('hidden');
|
||||
if (typeof syncRailSide === 'function') syncRailSide();
|
||||
} else {
|
||||
// Expanded — save width and sync
|
||||
const finalWidth = parseInt(sidebar.style.width, 10);
|
||||
if (finalWidth >= MIN_WIDTH) {
|
||||
Storage.set(STORAGE_KEY, String(finalWidth));
|
||||
}
|
||||
if (typeof syncRailSide === 'function') syncRailSide();
|
||||
}
|
||||
}
|
||||
|
||||
} // end if (sidebar && handle)
|
||||
}
|
||||
|
||||
/* ---- Mobile viewport fix — keep chat visible when virtual keyboard opens ---- */
|
||||
{
|
||||
if (window.visualViewport) {
|
||||
let _lastVVHeight = window.visualViewport.height;
|
||||
window.visualViewport.addEventListener('resize', function() {
|
||||
const vv = window.visualViewport;
|
||||
const keyboardOpened = vv.height < _lastVVHeight - 50;
|
||||
_lastVVHeight = vv.height;
|
||||
if (keyboardOpened) {
|
||||
var chatHistory = document.getElementById('chat-history');
|
||||
if (chatHistory) {
|
||||
requestAnimationFrame(function() {
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fade welcome screen when mobile keyboard opens (input focus/blur)
|
||||
if ('ontouchstart' in window) {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var _msgInput = document.getElementById('message');
|
||||
if (!_msgInput) return;
|
||||
_msgInput.addEventListener('focus', function() {
|
||||
var welcome = document.getElementById('welcome-screen');
|
||||
if (welcome && !welcome.classList.contains('hidden')) {
|
||||
welcome.style.transition = 'opacity 0.2s ease, transform 0.2s ease';
|
||||
welcome.style.opacity = '0';
|
||||
welcome.style.transform = 'translate(-50%, -50%) scale(0.92)';
|
||||
}
|
||||
});
|
||||
_msgInput.addEventListener('blur', function() {
|
||||
var welcome = document.getElementById('welcome-screen');
|
||||
if (welcome && !welcome.classList.contains('hidden')) {
|
||||
welcome.style.transition = 'opacity 0.3s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
welcome.style.opacity = '';
|
||||
welcome.style.transform = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Release welcome-screen entrance animations once the page is settled ──
|
||||
The splash's entrance animations (#welcome-screen / .welcome-name) are held
|
||||
by CSS (`body:not(.welcome-ready)`) until this runs, so they no longer play
|
||||
while fonts are loading and the layout is still shifting on first paint
|
||||
(which made the splash "go haywire"). We flip the flag after fonts are ready
|
||||
plus a couple of frames, with load + timeout fallbacks so the splash is never
|
||||
left hidden. Lives here (a network-first module) rather than inline in
|
||||
index.html so it updates in lockstep with the gating CSS. */
|
||||
(function () {
|
||||
let fired = false;
|
||||
function release() {
|
||||
if (fired) return;
|
||||
fired = true;
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => document.body.classList.add('welcome-ready'))
|
||||
);
|
||||
}
|
||||
try { if (document.fonts && document.fonts.ready) document.fonts.ready.then(release); } catch (_) {}
|
||||
if (document.readyState === 'complete') release();
|
||||
else window.addEventListener('load', release);
|
||||
setTimeout(release, 1200); // hard fallback — never leave the splash hidden
|
||||
})();
|
||||
Reference in New Issue
Block a user