314 lines
13 KiB
HTML
314 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
|
|
{# Apply saved theme before stylesheet renders to avoid a flash. #}
|
|
<script>
|
|
(function() {
|
|
try {
|
|
var t = localStorage.getItem('cassandra.theme') || 'light';
|
|
document.documentElement.dataset.theme = t;
|
|
} catch (e) { document.documentElement.dataset.theme = 'light'; }
|
|
})();
|
|
</script>
|
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
|
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
|
|
<script>
|
|
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
|
|
// HTMX request so AI-generated panels resolve to the right cached
|
|
// variant. Preference persists in localStorage; see toggle in header.
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
function currentTone() {
|
|
try {
|
|
var t = localStorage.getItem('cassandra.tone');
|
|
return (t === 'NOVICE' || t === 'INTERMEDIATE') ? t : 'INTERMEDIATE';
|
|
} catch (e) { return 'INTERMEDIATE'; }
|
|
}
|
|
document.body.addEventListener('htmx:configRequest', function (evt) {
|
|
evt.detail.parameters.tone = currentTone();
|
|
// News tag filters — only attach to /api/news requests.
|
|
if ((evt.detail.path || '').indexOf('/api/news') === 0) {
|
|
var inc = newsTags('include');
|
|
var exc = newsTags('exclude');
|
|
if (inc.length) evt.detail.parameters.tags = inc.join(',');
|
|
if (exc.length) evt.detail.parameters.exclude_tags = exc.join(',');
|
|
}
|
|
});
|
|
|
|
// News tag preference: include / exclude sets persisted in
|
|
// localStorage. Click cycles include → exclude → off;
|
|
// shift-click goes straight to exclude.
|
|
function newsTags(kind) {
|
|
try {
|
|
var raw = localStorage.getItem('cassandra.news.' + kind);
|
|
var arr = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(arr) ? arr : [];
|
|
} catch (e) { return []; }
|
|
}
|
|
function setNewsTags(kind, arr) {
|
|
try { localStorage.setItem('cassandra.news.' + kind, JSON.stringify(arr)); }
|
|
catch (e) {}
|
|
}
|
|
function refreshNewsPanels() {
|
|
document.querySelectorAll('[hx-get*="/api/news"]').forEach(function (el) {
|
|
if (window.htmx) window.htmx.trigger(el, 'tags-changed');
|
|
});
|
|
}
|
|
// Event delegation so HTMX-swapped pills work without rebinding.
|
|
document.addEventListener('click', function (e) {
|
|
var el = e.target.closest && e.target.closest('.news-tag');
|
|
if (!el) return;
|
|
e.preventDefault();
|
|
var tag = el.getAttribute('data-tag') || '';
|
|
if (el.classList.contains('news-tag--clear')) {
|
|
setNewsTags('include', []);
|
|
setNewsTags('exclude', []);
|
|
refreshNewsPanels();
|
|
return;
|
|
}
|
|
var inc = newsTags('include');
|
|
var exc = newsTags('exclude');
|
|
var inInc = inc.indexOf(tag);
|
|
var inExc = exc.indexOf(tag);
|
|
if (e.shiftKey) {
|
|
// Shift-click → toggle exclude membership; remove from include.
|
|
if (inInc >= 0) inc.splice(inInc, 1);
|
|
if (inExc >= 0) exc.splice(inExc, 1);
|
|
else exc.push(tag);
|
|
} else {
|
|
// Plain click → cycle: off → include → exclude → off.
|
|
if (inInc >= 0) {
|
|
inc.splice(inInc, 1);
|
|
exc.push(tag);
|
|
} else if (inExc >= 0) {
|
|
exc.splice(inExc, 1);
|
|
} else {
|
|
inc.push(tag);
|
|
}
|
|
}
|
|
setNewsTags('include', inc);
|
|
setNewsTags('exclude', exc);
|
|
refreshNewsPanels();
|
|
});
|
|
// Reflect the saved value in the toggle on load.
|
|
var pill = document.getElementById('tone-toggle');
|
|
if (pill) pill.dataset.tone = currentTone();
|
|
});
|
|
|
|
window.cassandraSetTone = function (newTone) {
|
|
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
|
|
var pill = document.getElementById('tone-toggle');
|
|
if (pill) pill.dataset.tone = newTone;
|
|
// Trigger a re-fetch of every AI-driven HTMX target on the page.
|
|
// Easiest: dispatch a custom event that the relevant elements
|
|
// listen to. Simpler still: fire htmx.trigger on the well-known
|
|
// panels. We use the simple path.
|
|
['#dash-header-container', '#log-panel .panel-body',
|
|
'#indicators-body', '#log-content'].forEach(function (sel) {
|
|
var el = document.querySelector(sel);
|
|
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
|
|
});
|
|
};
|
|
</script>
|
|
<script>
|
|
// Render any <time datetime="..."> in the browser's local timezone.
|
|
// Re-runs after every HTMX swap so freshly-loaded news rows pick up too.
|
|
function formatLocalTimes() {
|
|
document.querySelectorAll('time[datetime]:not([data-local])').forEach(function (t) {
|
|
try {
|
|
var d = new Date(t.getAttribute('datetime'));
|
|
if (isNaN(d.getTime())) return;
|
|
var date = d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
|
|
var time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
t.textContent = date + ' ' + time;
|
|
t.title = d.toLocaleString();
|
|
t.setAttribute('data-local', '1');
|
|
} catch (e) {}
|
|
});
|
|
}
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
formatLocalTimes();
|
|
document.body.addEventListener('htmx:afterSwap', formatLocalTimes);
|
|
});
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<header class="app-header">
|
|
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
|
|
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome at hello@read.markets">BETA</span>{% endif %}
|
|
<nav>
|
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
|
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
|
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
|
</nav>
|
|
<div class="header-right">
|
|
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
|
role="group" aria-label="Explanation level">
|
|
<button type="button" data-value="NOVICE"
|
|
onclick="cassandraSetTone('NOVICE')">Novice</button>
|
|
<button type="button" data-value="INTERMEDIATE"
|
|
onclick="cassandraSetTone('INTERMEDIATE')">Intermediate</button>
|
|
</div>
|
|
<button class="theme-toggle" type="button" aria-label="Toggle theme"
|
|
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
|
<span class="theme-toggle__label"></span>
|
|
</button>
|
|
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
|
{% if cu and (cu.user or cu.is_admin) %}
|
|
<div class="user-menu">
|
|
<button type="button" id="user-menu-toggle" class="user-chip"
|
|
aria-haspopup="true" aria-expanded="false">
|
|
{% if cu.user %}{{ cu.user.email }}{% else %}admin{% endif %}
|
|
<span class="user-menu__caret">▾</span>
|
|
</button>
|
|
<div id="user-menu" class="user-menu__panel" role="menu" hidden>
|
|
{% if cu.user %}
|
|
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
|
|
{% endif %}
|
|
<a href="/pricing" role="menuitem" class="user-menu__item">Pricing</a>
|
|
<a href="/terms" role="menuitem" class="user-menu__item">Terms</a>
|
|
<a href="/privacy" role="menuitem" class="user-menu__item">Privacy</a>
|
|
<a href="/disclaimer" role="menuitem" class="user-menu__item">Disclaimer</a>
|
|
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<span class="meta">v0.1 · UTC</span>
|
|
</div>
|
|
</header>
|
|
|
|
<script>
|
|
(function () {
|
|
var btn = document.getElementById('user-menu-toggle');
|
|
var menu = document.getElementById('user-menu');
|
|
if (!btn || !menu) return;
|
|
function close() { menu.hidden = true; btn.setAttribute('aria-expanded','false'); }
|
|
function open() { menu.hidden = false; btn.setAttribute('aria-expanded','true'); }
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
if (menu.hidden) open(); else close();
|
|
});
|
|
document.addEventListener('click', function (e) {
|
|
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
|
|
});
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') close();
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<main class="app-main">
|
|
{% block main %}{% endblock %}
|
|
</main>
|
|
|
|
{# Shared glossary tooltip (Novice mode). Single floating element
|
|
positioned by JS to escape sticky-bar stacking and viewport edges. #}
|
|
<div id="glossary-tooltip" role="tooltip" hidden></div>
|
|
<script>
|
|
(function () {
|
|
const tip = document.getElementById('glossary-tooltip');
|
|
let activeEl = null;
|
|
|
|
function position(el) {
|
|
// Measure after content is set so dimensions are accurate.
|
|
tip.style.left = '0px';
|
|
tip.style.top = '0px';
|
|
tip.hidden = false;
|
|
const rect = el.getBoundingClientRect();
|
|
const tipRect = tip.getBoundingClientRect();
|
|
const margin = 8;
|
|
|
|
// Decide above or below based on available space.
|
|
const spaceAbove = rect.top - margin;
|
|
const spaceBelow = window.innerHeight - rect.bottom - margin;
|
|
let top = (spaceAbove >= tipRect.height || spaceAbove >= spaceBelow)
|
|
? rect.top - tipRect.height - 6
|
|
: rect.bottom + 6;
|
|
|
|
// Clamp top into the viewport.
|
|
if (top < margin) top = margin;
|
|
if (top + tipRect.height > window.innerHeight - margin) {
|
|
top = window.innerHeight - tipRect.height - margin;
|
|
}
|
|
|
|
// Horizontal: anchor to term's left edge, clamp to viewport.
|
|
let left = rect.left;
|
|
if (left + tipRect.width > window.innerWidth - margin) {
|
|
left = window.innerWidth - tipRect.width - margin;
|
|
}
|
|
if (left < margin) left = margin;
|
|
|
|
tip.style.left = left + 'px';
|
|
tip.style.top = top + 'px';
|
|
tip.setAttribute('data-visible', '1');
|
|
}
|
|
|
|
function show(el) {
|
|
const def = el.getAttribute('data-def');
|
|
if (!def) return;
|
|
activeEl = el;
|
|
tip.textContent = def;
|
|
position(el);
|
|
}
|
|
|
|
function hide() {
|
|
activeEl = null;
|
|
tip.removeAttribute('data-visible');
|
|
tip.hidden = true;
|
|
}
|
|
|
|
// Event delegation with capture so we catch elements HTMX swaps in
|
|
// after page load.
|
|
document.addEventListener('mouseover', function (e) {
|
|
const el = e.target.closest && e.target.closest('.glossary');
|
|
if (el) show(el);
|
|
else if (activeEl && !e.target.closest('.glossary')) hide();
|
|
}, true);
|
|
document.addEventListener('focusin', function (e) {
|
|
if (e.target.classList && e.target.classList.contains('glossary')) {
|
|
show(e.target);
|
|
}
|
|
});
|
|
document.addEventListener('focusout', function (e) {
|
|
if (e.target.classList && e.target.classList.contains('glossary')) {
|
|
hide();
|
|
}
|
|
});
|
|
|
|
// Mobile / touch: tap to toggle, tap-elsewhere to dismiss.
|
|
document.addEventListener('click', function (e) {
|
|
const el = e.target.closest && e.target.closest('.glossary');
|
|
if (el) {
|
|
// Re-show (or toggle off if it's the currently-active one).
|
|
if (activeEl === el) hide();
|
|
else show(el);
|
|
e.preventDefault();
|
|
} else if (activeEl) {
|
|
hide();
|
|
}
|
|
}, true);
|
|
|
|
// Hide on scroll / resize so the tooltip doesn't drift away from
|
|
// its term.
|
|
window.addEventListener('scroll', hide, true);
|
|
window.addEventListener('resize', hide);
|
|
// Hide when HTMX swaps content (term may have been replaced).
|
|
document.body.addEventListener('htmx:beforeSwap', hide);
|
|
})();
|
|
</script>
|
|
|
|
<footer class="markets-bar"
|
|
hx-get="/api/markets-bar?as=html"
|
|
hx-trigger="load, every 60s"
|
|
hx-swap="innerHTML"
|
|
id="markets-bar">
|
|
<div class="markets-bar__inner">
|
|
<div class="markets-bar__list"><span class="empty">awaiting markets…</span></div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|