feat: mobile UI, chat history search, news source filtering

- Mobile (#11): responsive 3-button bottom nav (STATS/CHAT/INFO), panel switching, compact topbar, touch-friendly inputs; panels show one-at-a-time on screens <900px
- Search (#12): 🔍 button next to TRANSMIT opens search modal; history.php endpoint queries conversations table; results show role, timestamp, and snippet
- News filter (#13): ⚙ gear on NEWS tab reveals category checkboxes; hidden categories stored in localStorage; empty-state message when all hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 02:18:10 +00:00
parent 290389abef
commit c74a9af8be
3 changed files with 193 additions and 4 deletions
+21
View File
@@ -0,0 +1,21 @@
<?php
// Chat history search endpoint
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
AuthMiddleware::requireAuth();
$q = trim($_GET['q'] ?? '');
if (strlen($q) < 2) {
echo json_encode(['results' => [], 'error' => 'Query too short']);
exit;
}
$rows = JarvisDB::query(
"SELECT role, content, created_at FROM conversations
WHERE content LIKE ? ORDER BY created_at DESC LIMIT 25",
['%' . $q . '%']
) ?? [];
echo json_encode(['results' => $rows, 'total' => count($rows)]);
+1
View File
@@ -72,6 +72,7 @@ $endpoints = [
'agent' => 'agent.php',
'planner' => 'planner.php',
'jellyfin' => 'jellyfin.php',
'history' => 'history.php',
'arc' => 'arc.php',
'directives' => 'directives.php',
'memory' => 'memory.php',
+171 -4
View File
@@ -301,11 +301,39 @@ body::after{
#mainLayout.swapped.focus-mode #rightPanel{transform:translateX(-20px)}
#btn-swap-panels{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 8px;cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;letter-spacing:1px;transition:all 0.2s}
#btn-swap-panels:hover,#btn-swap-panels.active{color:var(--cyan);border-color:var(--cyan)}
/* Mobile fallback */
/* ── MOBILE RESPONSIVE ──────────────────────────────────────────────── */
@media(max-width:900px){
#mainLayout{grid-template-columns:1fr;grid-template-rows:auto}
#mainLayout{grid-template-columns:1fr!important;grid-template-rows:1fr!important;padding:6px;gap:6px}
#leftPanel,#rightPanel{display:none}
#leftPanel.mob-active,#rightPanel.mob-active{display:flex!important;flex-direction:column}
#centerPanel{display:none}
#centerPanel.mob-active{display:flex!important;flex-direction:column}
#topBar{padding:0 10px;height:42px}
.tb-center{display:none}
.tb-logo{font-size:0.85rem;letter-spacing:2px}
#clock{font-size:0.85rem}
#date-display{display:none}
#btn-swap-panels,.btn-panels{display:none}
#inputArea{padding:8px 6px}
#textInput{font-size:0.85rem;padding:8px 10px;min-height:40px}
#sendBtn{min-height:40px;padding:0 14px;font-size:0.65rem}
#micBtn{min-height:40px;min-width:40px;font-size:1.1rem}
#app{padding-bottom:48px}
#mobileNav{display:flex!important}
}
@media(min-width:901px){#mobileNav{display:none!important}}
#mobileNav{
display:none;position:fixed;bottom:0;left:0;right:0;z-index:100;
background:rgba(0,8,22,0.96);border-top:1px solid var(--panel-border);height:48px;
}
.mob-nav-btn{
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
font-family:var(--font-display);font-size:0.5rem;letter-spacing:1.5px;
color:var(--text-dim);cursor:pointer;gap:2px;border:none;background:none;transition:color 0.2s;
}
.mob-nav-btn .mob-icon{font-size:1rem;line-height:1}
.mob-nav-btn.active{color:var(--cyan)}
.mob-nav-btn.active .mob-icon{filter:drop-shadow(0 0 4px var(--cyan))}
/* Panel toggle button */
.btn-panels{
background:rgba(0,212,255,0.06);
@@ -1210,6 +1238,7 @@ body::after{
<input type="text" id="textInput" placeholder="Enter command or speak to JARVIS..."
autocomplete="off" onkeydown="if(event.key==='Enter')sendMessage()"/>
<button id="sendBtn" onclick="sendMessage()">TRANSMIT</button>
<button id="searchBtn" onclick="openSearchModal()" title="Search chat history" style="background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);font-size:1rem;padding:0 10px;cursor:pointer;transition:all 0.2s;min-height:36px" onmouseover="this.style.color='var(--cyan)';this.style.borderColor='var(--cyan)'" onmouseout="this.style.color='var(--text-dim)';this.style.borderColor='var(--panel-border)'">🔍</button>
</div>
</div>
</div>
@@ -1283,6 +1312,13 @@ body::after{
<div id="alerts-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-news" class="tab-pane" style="overflow-y:auto;flex:1">
<div style="display:flex;align-items:center;justify-content:flex-end;margin-bottom:4px">
<button onclick="toggleNewsFilter()" title="Filter news sources" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:0.85rem;padding:2px 4px;transition:color 0.2s" onmouseover="this.style.color='var(--cyan)'" onmouseout="this.style.color='var(--text-dim)'"></button>
</div>
<div id="news-filter-panel" style="display:none;margin-bottom:8px;padding:8px;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px">
<div style="font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;color:var(--cyan);margin-bottom:6px">SHOW CATEGORIES</div>
<div id="news-filter-checkboxes" style="display:flex;flex-direction:column;gap:4px"></div>
</div>
<div id="news-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-agents" class="tab-pane" style="overflow-y:auto;flex:1">
@@ -2547,6 +2583,7 @@ function showApp(name, greeting, silent = false) {
loadAlerts();
loadWeather();
loadNews();
initMobile();
// Guardian Mode — badge refresh + proactive chat
setTimeout(() => {
_refreshGuardianBadge();
@@ -3231,6 +3268,17 @@ async function loadWeather() {
}
// ── NEWS ──────────────────────────────────────────────────────────────
function getNewsHidden() {
try { return JSON.parse(localStorage.getItem('news_hidden_cats') || '[]'); } catch(e) { return []; }
}
function setNewsHidden(arr) { localStorage.setItem('news_hidden_cats', JSON.stringify(arr)); }
function toggleNewsFilter() {
const panel = document.getElementById('news-filter-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
let _newsCats = [];
async function loadNews() {
const d = await api('news');
const el = document.getElementById('news-list');
@@ -3238,10 +3286,24 @@ async function loadNews() {
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">News loading...</div>';
return;
}
const catLabels = { headlines: '📰 TOP HEADLINES', technology: '💻 TECHNOLOGY' };
const catLabels = { headlines: '📰 TOP HEADLINES', technology: '💻 TECHNOLOGY', pinned: '📌 JARVIS PINNED' };
const hidden = getNewsHidden();
_newsCats = Object.keys(d.categories);
// Build filter checkboxes
const cbContainer = document.getElementById('news-filter-checkboxes');
if (cbContainer) {
cbContainer.innerHTML = _newsCats.map(cat => `
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.6rem;color:var(--text-primary)">
<input type="checkbox" ${hidden.includes(cat) ? '' : 'checked'} onchange="toggleNewsCat('${cat}',this.checked)"
style="accent-color:var(--cyan)"/>
${catLabels[cat] || cat.toUpperCase()}
</label>`).join('');
}
let html = '';
for (const [cat, articles] of Object.entries(d.categories)) {
if (!articles.length) continue;
if (!articles.length || hidden.includes(cat)) continue;
html += `<div class="news-cat-header">${catLabels[cat] || cat.toUpperCase()}</div>`;
for (const a of articles.slice(0, 5)) {
const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30);
@@ -3254,11 +3316,24 @@ async function loadNews() {
</div>`;
}
}
if (!html) html = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">All categories hidden — use ⚙ to show sources</div>';
const ageMin = d.cache_age_s > 0 ? Math.round(d.cache_age_s/60) : 0;
html += `<div style="font-size:0.5rem;color:var(--text-dim);text-align:right;margin-top:8px">Updated ${ageMin}m ago</div>`;
el.innerHTML = html;
}
function toggleNewsCat(cat, show) {
const hidden = getNewsHidden();
if (show) {
const idx = hidden.indexOf(cat);
if (idx > -1) hidden.splice(idx, 1);
} else {
if (!hidden.includes(cat)) hidden.push(cat);
}
setNewsHidden(hidden);
loadNews();
}
// ── TABS ──────────────────────────────────────────────────────────────
function switchTab(name) {
if (name === 'sites') { openSitesModal(); return; }
@@ -4996,6 +5071,68 @@ document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeVisionLightbox();
});
// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
function openSearchModal() {
document.getElementById('searchModal').style.display = 'flex';
document.getElementById('searchInput').focus();
}
function closeSearchModal() {
document.getElementById('searchModal').style.display = 'none';
document.getElementById('searchResults').innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>';
document.getElementById('searchInput').value = '';
}
async function runSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
const el = document.getElementById('searchResults');
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Searching...</div>';
try {
const d = await api('history?q=' + encodeURIComponent(q));
if (!d.results || !d.results.length) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">No results for "' + q + '"</div>';
return;
}
el.innerHTML = d.results.map(r => {
const role = r.role === 'user' ? '👤' : '🤖';
const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
return `<div style="background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:10px 12px">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:var(--cyan)">${role} ${r.role.toUpperCase()}</span>
<span style="font-size:0.52rem;color:var(--text-dim)">${ts}</span>
</div>
<div style="font-size:0.68rem;color:var(--text-primary);line-height:1.4">${snippet.replace(/</g,'&lt;')}</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = '<div style="color:var(--red);font-size:0.65rem;text-align:center;padding:20px">Search failed</div>';
}
}
document.getElementById('searchModal')?.addEventListener('click', e => {
if (e.target === document.getElementById('searchModal')) closeSearchModal();
});
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
function mobSwitch(which) {
if (window.innerWidth > 900) return;
const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
Object.entries(panels).forEach(([k, id]) => {
document.getElementById(id)?.classList.toggle('mob-active', k === which);
});
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('mob-btn-' + which)?.classList.add('active');
if (which === 'right') loadNews();
}
function initMobile() {
if (window.innerWidth > 900) return;
['leftPanel','centerPanel','rightPanel'].forEach(id =>
document.getElementById(id)?.classList.remove('mob-active'));
document.getElementById('leftPanel')?.classList.add('mob-active');
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('mob-btn-left')?.classList.add('active');
}
window.addEventListener('resize', initMobile);
</script>
<!-- VISION LIGHTBOX -->
@@ -5009,5 +5146,35 @@ document.addEventListener('keydown', e => {
<pre id="vision-lb-analysis"></pre>
</div>
<!-- CHAT HISTORY SEARCH MODAL -->
<div id="searchModal" style="display:none;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);align-items:center;justify-content:center">
<div style="background:#000d1f;border:1px solid var(--panel-border);border-radius:6px;padding:20px;width:min(620px,94vw);max-height:80vh;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="font-family:var(--font-display);font-size:0.75rem;letter-spacing:3px;color:var(--cyan)">◈ CHAT HISTORY SEARCH</div>
<button onclick="closeSearchModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1rem"></button>
</div>
<div style="display:flex;gap:8px">
<input id="searchInput" type="text" placeholder="Search conversations…"
style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);color:var(--text-primary);font-family:var(--font-mono);font-size:0.75rem;padding:8px 12px;outline:none"
onkeydown="if(event.key==='Enter')runSearch()" autocomplete="off"/>
<button onclick="runSearch()" style="background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-display);font-size:0.6rem;letter-spacing:2px;padding:8px 16px;cursor:pointer">SEARCH</button>
</div>
<div id="searchResults" style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:6px;min-height:60px">
<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>
</div>
</div>
</div>
<nav id="mobileNav">
<button class="mob-nav-btn active" id="mob-btn-left" onclick="mobSwitch('left')">
<span class="mob-icon">📊</span>STATS
</button>
<button class="mob-nav-btn" id="mob-btn-center" onclick="mobSwitch('center')">
<span class="mob-icon">💬</span>CHAT
</button>
<button class="mob-nav-btn" id="mob-btn-right" onclick="mobSwitch('right')">
<span class="mob-icon">🛰</span>INFO
</button>
</nav>
</body>
</html>