Milestone 8 (phases 1-4): - Shard-aware WAL segment naming, BatchHeader v2, ShardRouter - Transport trait, InProcessTransport, WalShipper, FollowerDb - HLC, PNCounter, LWWRegister, CrdtSignalState, ReconciliationEngine - Session replication bridge with SeqNo/HWM, idempotency store Forage application: - Multi-source discovery engine with MAB exploration - Embedding-based label system, server handlers, UI refresh Other: - QUICKSTART.md, README.md, milestone-8 planning docs - Hard negative union semantics, RLHF export enhancements - Recovery benchmark and visibility test expansions - Split 8 oversized source files per CODING_GUIDELINES §9 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
634 lines
24 KiB
HTML
634 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Forage</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f0f0f; color: #e0e0e0; min-height: 100vh; }
|
|
header { display: flex; align-items: center; gap: 16px; padding: 16px 24px; border-bottom: 1px solid #222; flex-wrap: wrap; }
|
|
header h1 { font-size: 1.4rem; font-weight: 700; color: #fff; }
|
|
header select { background: #1a1a1a; color: #e0e0e0; border: 1px solid #333; border-radius: 6px; padding: 6px 10px; font-size: 0.9rem; cursor: pointer; }
|
|
#interests { font-size: 0.8rem; color: #666; margin-left: 4px; transition: color 0.4s; }
|
|
#interests.active { color: #4ade80; }
|
|
#feed { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; padding: 24px; }
|
|
.card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 10px; padding: 18px; display: flex; flex-direction: column; gap: 10px; transition: border-color 0.2s; position: relative; overflow: hidden; }
|
|
.card:hover { border-color: #444; }
|
|
.card.removing { opacity: 0; transform: scale(0.95); transition: all 0.2s; }
|
|
.dwell-bar { position: absolute; bottom: 0; left: 0; height: 2px; width: 0%; background: #4ade80; transition: none; opacity: 0; }
|
|
.card.dwelling .dwell-bar { opacity: 1; }
|
|
.card.dwell-done .dwell-bar { width: 100% !important; background: #4ade80; opacity: 0; transition: opacity 0.6s 0.2s; }
|
|
.card-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.chip { font-size: 0.72rem; font-weight: 600; padding: 3px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.chip-category { background: #222; color: #aaa; }
|
|
.chip-match { background: #0d2e1a; color: #4ade80; }
|
|
.chip-exploring { background: #1e0d2e; color: #c084fc; }
|
|
.chip-trending { background: #2e1a0d; color: #fb923c; }
|
|
.chip-resurfaced { background: #0d1e2e; color: #60a5fa; }
|
|
.chip-discovered { background: #1e1a0d; color: #fbbf24; }
|
|
.chip-bridge { background: #0d2e2e; color: #2dd4bf; }
|
|
.reading-time { font-size: 0.75rem; color: #666; margin-left: auto; }
|
|
.card-title { font-size: 1rem; font-weight: 600; color: #f0f0f0; line-height: 1.4; cursor: pointer; }
|
|
.card-title:hover { color: #fff; text-decoration: underline; }
|
|
.card-source { font-size: 0.78rem; color: #666; }
|
|
.card-desc { font-size: 0.85rem; color: #aaa; line-height: 1.5; }
|
|
.card-actions { display: flex; gap: 8px; margin-top: 4px; }
|
|
.btn { font-size: 0.8rem; padding: 6px 14px; border-radius: 6px; border: 1px solid #333; background: #222; color: #ccc; cursor: pointer; transition: background 0.15s; }
|
|
.btn:hover { background: #2a2a2a; color: #fff; }
|
|
.btn-save.saved { background: #1a2e1a; border-color: #4ade80; color: #4ade80; }
|
|
.score { position: absolute; top: 12px; right: 14px; font-size: 0.68rem; color: #444; }
|
|
#status { padding: 8px 24px; font-size: 0.8rem; color: #555; }
|
|
.loading { text-align: center; padding: 60px; color: #555; }
|
|
#toast { position: fixed; bottom: 24px; right: 24px; background: #1a2e1a; border: 1px solid #4ade80; color: #4ade80; padding: 10px 18px; border-radius: 8px; font-size: 0.82rem; opacity: 0; transform: translateY(8px); transition: opacity 0.25s, transform 0.25s; pointer-events: none; z-index: 100; }
|
|
#toast.show { opacity: 1; transform: translateY(0); }
|
|
|
|
/* ── Auth modal ── */
|
|
#auth-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 200; align-items: center; justify-content: center; }
|
|
#auth-modal.show { display: flex; }
|
|
#auth-box { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 28px; width: 320px; display: flex; flex-direction: column; gap: 14px; }
|
|
#auth-box h2 { font-size: 1rem; color: #fff; }
|
|
#auth-box p { font-size: 0.82rem; color: #888; }
|
|
#auth-token-input { background: #111; border: 1px solid #333; color: #e0e0e0; border-radius: 6px; padding: 8px 10px; font-size: 0.9rem; }
|
|
#auth-token-input:focus { outline: none; border-color: #4ade80; }
|
|
#auth-submit { background: #1a2e1a; border: 1px solid #4ade80; color: #4ade80; padding: 8px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
|
|
#auth-submit:hover { background: #223e22; }
|
|
#auth-error { font-size: 0.78rem; color: #f87171; min-height: 18px; }
|
|
|
|
/* ── Discovery status bar ── */
|
|
#discovery-status {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 6px 24px; font-size: 0.78rem; color: #555;
|
|
border-bottom: 1px solid #1a1a1a;
|
|
}
|
|
#discovery-status .dot {
|
|
width: 7px; height: 7px; border-radius: 50%; background: #333; flex-shrink: 0;
|
|
}
|
|
#discovery-status .dot.active { background: #4ade80; }
|
|
#discovery-status .dot.discovering {
|
|
background: #fb923c;
|
|
animation: pulse 1.2s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
|
|
|
/* ── Tag chips ── */
|
|
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.chip-tag {
|
|
font-size: 0.68rem; padding: 2px 7px; border-radius: 999px;
|
|
border: 1px solid #333; color: #888; background: transparent;
|
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
|
|
}
|
|
.chip-tag:hover { border-color: #555; color: #bbb; }
|
|
.chip-tag-selected {
|
|
border-color: #4ade80; background: #1a3a22; color: #4ade80;
|
|
}
|
|
.chip-tag-selected:hover { border-color: #6aee9a; background: #22472a; }
|
|
|
|
/* ── Tag preferences bar ── */
|
|
#tag-prefs-bar {
|
|
padding: 4px 24px 6px; font-size: 0.76rem; color: #555;
|
|
border-bottom: 1px solid #1a1a1a; min-height: 24px;
|
|
}
|
|
#tag-prefs-bar .tag-prefs-clear {
|
|
margin-left: 8px; font-size: 0.72rem; color: #4ade80;
|
|
cursor: pointer; text-decoration: underline; background: none;
|
|
border: none; padding: 0; font-family: inherit;
|
|
}
|
|
#tag-prefs-bar .tag-prefs-clear:hover { color: #6aee9a; }
|
|
|
|
/* ── Content type badge ── */
|
|
.chip-content-type {
|
|
font-size: 0.68rem; font-weight: 600; padding: 2px 7px; border-radius: 999px;
|
|
text-transform: uppercase; letter-spacing: 0.04em;
|
|
}
|
|
.chip-ct-analysis { background: #0d1e3a; color: #60a5fa; }
|
|
.chip-ct-tutorial { background: #0d2e1a; color: #4ade80; }
|
|
.chip-ct-news { background: #222; color: #888; }
|
|
.chip-ct-opinion { background: #2e1e0a; color: #fb923c; }
|
|
.chip-ct-review { background: #1e0d2e; color: #c084fc; }
|
|
.chip-ct-interview { background: #0d2e2a; color: #2dd4bf; }
|
|
.chip-ct-research { background: #1a2e0d; color: #a3e635; }
|
|
|
|
/* ── SSE new-capture flash ── */
|
|
@keyframes newCapture {
|
|
0% { border-color: #4ade80; box-shadow: 0 0 14px rgba(74,222,128,0.35); }
|
|
100% { border-color: #2a2a2a; box-shadow: none; }
|
|
}
|
|
.card.new-capture { animation: newCapture 2s ease-out forwards; }
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Forage</h1>
|
|
<label style="font-size:0.85rem;color:#888;">User:</label>
|
|
<select id="user-select">
|
|
<option value="1">User 1 — Cold Start</option>
|
|
<option value="2">User 2 — Explorer</option>
|
|
<option value="3">User 3 — Converged</option>
|
|
</select>
|
|
<button class="btn" onclick="fetchFeed()">Refresh</button>
|
|
<span id="interests"></span>
|
|
<span id="status"></span>
|
|
</header>
|
|
<div id="discovery-status">
|
|
<span class="dot" id="agent-dot"></span>
|
|
<span id="agent-label">Checking agent status…</span>
|
|
</div>
|
|
<div id="tag-prefs-bar"></div>
|
|
<div id="feed"><div class="loading">Loading feed…</div></div>
|
|
<div id="toast"></div>
|
|
|
|
<!-- Auth modal: shown when a request returns 401 -->
|
|
<div id="auth-modal">
|
|
<div id="auth-box">
|
|
<h2>Authentication required</h2>
|
|
<p>This Forage server requires a token. Enter it below to continue.</p>
|
|
<input type="password" id="auth-token-input" placeholder="Bearer token">
|
|
<div id="auth-error"></div>
|
|
<button id="auth-submit">Connect</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let currentUser = 1;
|
|
let itemMeta = {};
|
|
const BASE = '';
|
|
|
|
// Auto-refresh pauses while any card is being read.
|
|
let dwellActive = false;
|
|
let refreshTimer = null;
|
|
|
|
// ── Auth helpers ────────────────────────────────────────────────────────────
|
|
|
|
function getToken() {
|
|
return localStorage.getItem('forage_token') || '';
|
|
}
|
|
|
|
function setToken(t) {
|
|
localStorage.setItem('forage_token', t);
|
|
}
|
|
|
|
function authHeaders() {
|
|
const h = { 'Content-Type': 'application/json' };
|
|
const t = getToken();
|
|
if (t) h['Authorization'] = `Bearer ${t}`;
|
|
return h;
|
|
}
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
|
const res = await fetch(`${BASE}${path}`, { ...options, headers });
|
|
if (res.status === 401) {
|
|
showAuthModal();
|
|
return null;
|
|
}
|
|
return res;
|
|
}
|
|
|
|
// ── Auth modal ──────────────────────────────────────────────────────────────
|
|
|
|
function showAuthModal() {
|
|
document.getElementById('auth-modal').classList.add('show');
|
|
document.getElementById('auth-token-input').focus();
|
|
}
|
|
|
|
function hideAuthModal() {
|
|
document.getElementById('auth-modal').classList.remove('show');
|
|
document.getElementById('auth-error').textContent = '';
|
|
}
|
|
|
|
document.getElementById('auth-submit').addEventListener('click', async () => {
|
|
const token = document.getElementById('auth-token-input').value.trim();
|
|
if (!token) return;
|
|
setToken(token);
|
|
// Verify the token works
|
|
const res = await fetch(`${BASE}/prefs?user=${currentUser}`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
hideAuthModal();
|
|
loadItemMeta().then(() => { fetchFeed(); fetchPrefs(); });
|
|
} else {
|
|
document.getElementById('auth-error').textContent = 'Invalid token — try again';
|
|
}
|
|
});
|
|
|
|
document.getElementById('auth-token-input').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') document.getElementById('auth-submit').click();
|
|
});
|
|
|
|
// ── Tag preferences ─────────────────────────────────────────────────────────
|
|
|
|
function getTagPrefs() {
|
|
return localStorage.getItem('forage_tag_prefs')?.split(',').filter(Boolean) ?? [];
|
|
}
|
|
|
|
function setTagPrefs(tags) {
|
|
localStorage.setItem('forage_tag_prefs', tags.join(','));
|
|
}
|
|
|
|
function updateTagPrefsBar() {
|
|
const bar = document.getElementById('tag-prefs-bar');
|
|
if (!bar) return;
|
|
bar.innerHTML = '';
|
|
const prefs = getTagPrefs();
|
|
if (prefs.length === 0) return;
|
|
const text = document.createTextNode(`\uD83D\uDCCC Preferred: ${prefs.join(', ')} `);
|
|
bar.appendChild(text);
|
|
const clearBtn = document.createElement('button');
|
|
clearBtn.className = 'tag-prefs-clear';
|
|
clearBtn.textContent = 'Clear';
|
|
clearBtn.addEventListener('click', () => {
|
|
setTagPrefs([]);
|
|
updateTagPrefsBar();
|
|
// Deselect all visible tag chips
|
|
document.querySelectorAll('.chip-tag-selected').forEach(el => {
|
|
el.classList.remove('chip-tag-selected');
|
|
});
|
|
});
|
|
bar.appendChild(clearBtn);
|
|
}
|
|
|
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
|
|
function scheduleRefresh() {
|
|
clearTimeout(refreshTimer);
|
|
refreshTimer = setTimeout(() => {
|
|
if (!dwellActive) fetchFeed();
|
|
scheduleRefresh();
|
|
}, 8000);
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.classList.add('show');
|
|
setTimeout(() => el.classList.remove('show'), 2800);
|
|
}
|
|
|
|
function labelKey(label) {
|
|
if (typeof label === 'string') return label;
|
|
if (label && typeof label === 'object' && 'bridge' in label) return 'bridge';
|
|
return 'match';
|
|
}
|
|
|
|
function labelClass(label) {
|
|
const map = { match: 'chip-match', exploring: 'chip-exploring', trending: 'chip-trending', resurfaced: 'chip-resurfaced', bridge: 'chip-bridge', captured: 'chip-exploring' };
|
|
return map[labelKey(label)] || 'chip-match';
|
|
}
|
|
|
|
function labelText(label) {
|
|
if (labelKey(label) === 'bridge') {
|
|
const { cat_a, cat_b } = label.bridge;
|
|
return `bridge: ${cat_a} \u00d7 ${cat_b}`;
|
|
}
|
|
const map = { match: 'Match', exploring: 'Exploring', trending: 'Trending', resurfaced: 'Resurfaced', captured: 'Captured' };
|
|
return map[label] || label;
|
|
}
|
|
|
|
function categoryClass(category) {
|
|
return category === 'discovered' ? 'chip-discovered' : 'chip-category';
|
|
}
|
|
|
|
// ── Signal posting ──────────────────────────────────────────────────────────
|
|
|
|
async function postSignal(userId, itemId, signalType, durationMs) {
|
|
const body = { user_id: userId, item_id: itemId, signal_type: signalType };
|
|
if (durationMs !== undefined) body.duration_ms = durationMs;
|
|
await apiFetch('/signal', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// ── Discovery status ────────────────────────────────────────────────────────
|
|
|
|
async function pollDiscoveryStatus() {
|
|
try {
|
|
const res = await apiFetch('/discovery/status');
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
const dot = document.getElementById('agent-dot');
|
|
const label = document.getElementById('agent-label');
|
|
if (data.agent_connected) {
|
|
dot.className = 'dot active';
|
|
const mins = data.last_run_seconds_ago != null
|
|
? Math.round(data.last_run_seconds_ago / 60)
|
|
: null;
|
|
label.textContent = mins != null
|
|
? `Active — last run ${mins} min ago · ${data.items_found_last_run} items`
|
|
: 'Active — no runs yet';
|
|
} else {
|
|
dot.className = 'dot';
|
|
label.textContent = 'Agent not connected — run ./forage-discover.sh to start discovery';
|
|
}
|
|
} catch {
|
|
// server unreachable or auth failed — leave as is
|
|
}
|
|
setTimeout(pollDiscoveryStatus, 30_000);
|
|
}
|
|
|
|
// ── Feed ────────────────────────────────────────────────────────────────────
|
|
|
|
async function fetchPrefs() {
|
|
try {
|
|
const res = await apiFetch(`/prefs?user=${currentUser}`);
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
const el = document.getElementById('interests');
|
|
if (data.has_preferences && data.categories.length > 0) {
|
|
el.textContent = `Interests: ${data.categories.join(', ')}`;
|
|
el.classList.add('active');
|
|
setTimeout(() => el.classList.remove('active'), 1500);
|
|
} else {
|
|
el.textContent = 'No preferences yet — browse some pages!';
|
|
el.classList.remove('active');
|
|
}
|
|
} catch (e) {
|
|
// non-fatal
|
|
}
|
|
}
|
|
|
|
function makeDwellBar() {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'dwell-bar';
|
|
return bar;
|
|
}
|
|
|
|
function makeCard(item) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.dataset.id = item.id;
|
|
|
|
const meta = itemMeta[item.id] || {};
|
|
const url = item.url || meta.url || '#';
|
|
|
|
// Score badge
|
|
const scoreSpan = document.createElement('span');
|
|
scoreSpan.className = 'score';
|
|
scoreSpan.textContent = `score: ${(item.score || 0).toFixed(3)}`;
|
|
card.appendChild(scoreSpan);
|
|
|
|
// Meta row: category chip, label chip, content type badge, reading time
|
|
const metaDiv = document.createElement('div');
|
|
metaDiv.className = 'card-meta';
|
|
|
|
const catChip = document.createElement('span');
|
|
catChip.className = `chip ${categoryClass(item.category)}`;
|
|
catChip.textContent = item.category;
|
|
metaDiv.appendChild(catChip);
|
|
|
|
const lblChip = document.createElement('span');
|
|
lblChip.className = `chip ${labelClass(item.label)}`;
|
|
lblChip.textContent = labelText(item.label);
|
|
metaDiv.appendChild(lblChip);
|
|
|
|
if (item.content_type) {
|
|
const ctChip = document.createElement('span');
|
|
ctChip.className = `chip chip-content-type chip-ct-${item.content_type}`;
|
|
ctChip.textContent = item.content_type;
|
|
metaDiv.appendChild(ctChip);
|
|
}
|
|
|
|
const readTime = document.createElement('span');
|
|
readTime.className = 'reading-time';
|
|
readTime.textContent = `${item.reading_time_min || meta.reading_time_min || '?'} min`;
|
|
metaDiv.appendChild(readTime);
|
|
card.appendChild(metaDiv);
|
|
|
|
// Title
|
|
const titleDiv = document.createElement('div');
|
|
titleDiv.className = 'card-title';
|
|
titleDiv.textContent = item.title;
|
|
card.appendChild(titleDiv);
|
|
|
|
// Source
|
|
const sourceDiv = document.createElement('div');
|
|
sourceDiv.className = 'card-source';
|
|
sourceDiv.textContent = item.source;
|
|
card.appendChild(sourceDiv);
|
|
|
|
// Description (prefer summary when available)
|
|
const desc = document.createElement('div');
|
|
desc.className = 'card-desc';
|
|
desc.textContent = (item.summary && item.summary.length > 0) ? item.summary : (item.description || meta.description || '');
|
|
card.appendChild(desc);
|
|
|
|
// Tag chips (up to 3)
|
|
if (item.tags && item.tags.length > 0) {
|
|
const tagsDiv = document.createElement('div');
|
|
tagsDiv.className = 'card-tags';
|
|
item.tags.slice(0, 3).forEach(tag => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'chip-tag';
|
|
if (getTagPrefs().includes(tag)) chip.classList.add('chip-tag-selected');
|
|
chip.textContent = tag;
|
|
chip.addEventListener('click', () => {
|
|
const prefs = getTagPrefs();
|
|
const idx = prefs.indexOf(tag);
|
|
if (idx === -1) {
|
|
prefs.push(tag);
|
|
} else {
|
|
prefs.splice(idx, 1);
|
|
}
|
|
setTagPrefs(prefs);
|
|
chip.classList.toggle('chip-tag-selected', prefs.includes(tag));
|
|
updateTagPrefsBar();
|
|
});
|
|
tagsDiv.appendChild(chip);
|
|
});
|
|
card.appendChild(tagsDiv);
|
|
}
|
|
|
|
// Actions
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'card-actions';
|
|
actionsDiv.innerHTML = `
|
|
<button class="btn btn-skip">Skip</button>
|
|
<button class="btn btn-save">Save</button>
|
|
<button class="btn btn-share">Share</button>
|
|
`;
|
|
card.appendChild(actionsDiv);
|
|
|
|
const bar = makeDwellBar();
|
|
card.appendChild(bar);
|
|
|
|
card.querySelector('.card-title').addEventListener('click', () => {
|
|
postSignal(currentUser, item.id, 'view');
|
|
window.open(url, '_blank', 'noopener');
|
|
});
|
|
|
|
const DWELL_MS = 3000;
|
|
const COMPLETION_MS = 15000;
|
|
let dwellStart = null;
|
|
let dwellRaf = null;
|
|
|
|
function animateBar(elapsed) {
|
|
const pct = Math.min(elapsed / COMPLETION_MS * 100, 100);
|
|
bar.style.width = pct + '%';
|
|
if (elapsed < COMPLETION_MS && dwellStart !== null) {
|
|
dwellRaf = requestAnimationFrame(() => animateBar(Date.now() - dwellStart));
|
|
}
|
|
}
|
|
|
|
card.addEventListener('mouseenter', () => {
|
|
dwellStart = Date.now();
|
|
dwellActive = true;
|
|
card.classList.add('dwelling');
|
|
dwellRaf = requestAnimationFrame(() => animateBar(0));
|
|
});
|
|
|
|
card.addEventListener('mouseleave', () => {
|
|
if (dwellStart) {
|
|
const dur = Date.now() - dwellStart;
|
|
cancelAnimationFrame(dwellRaf);
|
|
dwellStart = null;
|
|
card.classList.remove('dwelling');
|
|
|
|
if (dur >= DWELL_MS) {
|
|
postSignal(currentUser, item.id, 'dwell', dur).then(() => {
|
|
if (dur >= COMPLETION_MS) {
|
|
card.classList.add('dwell-done');
|
|
setTimeout(() => card.classList.remove('dwell-done'), 1000);
|
|
showToast(`Reading noted ✓ Updating interests…`);
|
|
fetchPrefs();
|
|
}
|
|
});
|
|
}
|
|
|
|
bar.style.width = '0%';
|
|
}
|
|
setTimeout(() => {
|
|
const anyHovered = document.querySelector('.card.dwelling');
|
|
if (!anyHovered) dwellActive = false;
|
|
}, 50);
|
|
});
|
|
|
|
card.querySelector('.btn-skip').addEventListener('click', () => {
|
|
postSignal(currentUser, item.id, 'skip');
|
|
card.classList.add('removing');
|
|
setTimeout(() => { card.remove(); }, 200);
|
|
});
|
|
|
|
const saveBtn = card.querySelector('.btn-save');
|
|
saveBtn.addEventListener('click', () => {
|
|
postSignal(currentUser, item.id, 'save');
|
|
saveBtn.classList.toggle('saved');
|
|
saveBtn.textContent = saveBtn.classList.contains('saved') ? 'Saved ✓' : 'Save';
|
|
});
|
|
|
|
card.querySelector('.btn-share').addEventListener('click', () => {
|
|
postSignal(currentUser, item.id, 'share').then(() => {
|
|
showToast(`Shared ✓ Updating interests…`);
|
|
fetchPrefs();
|
|
});
|
|
});
|
|
|
|
return card;
|
|
}
|
|
|
|
// ── SSE live capture ────────────────────────────────────────────────────────
|
|
|
|
// Insert a newly captured item at the top of the feed without re-rendering
|
|
// existing cards (preserves dwell state, scroll position, everything).
|
|
function prependCard(event) {
|
|
const item = {
|
|
id: event.item_id,
|
|
title: event.title,
|
|
url: event.url,
|
|
source: event.source,
|
|
reading_time_min: event.reading_time_min,
|
|
description: event.description,
|
|
category: event.category || 'discovered',
|
|
label: 'captured',
|
|
score: 0,
|
|
tags: event.tags || [],
|
|
entities: event.entities || [],
|
|
content_type: event.content_type || '',
|
|
summary: event.summary || '',
|
|
};
|
|
// Keep itemMeta current so card fallback lookups work.
|
|
itemMeta[item.id] = item;
|
|
|
|
const feed = document.getElementById('feed');
|
|
// Remove the empty-state placeholder if present.
|
|
const placeholder = feed.querySelector('.loading');
|
|
if (placeholder) placeholder.remove();
|
|
|
|
const card = makeCard(item);
|
|
card.classList.add('new-capture');
|
|
feed.insertBefore(card, feed.firstChild);
|
|
|
|
// Trim to a reasonable max so the page doesn't grow forever.
|
|
const cards = feed.querySelectorAll('.card');
|
|
if (cards.length > 12) cards[cards.length - 1].remove();
|
|
}
|
|
|
|
function connectSSE() {
|
|
const token = getToken();
|
|
const url = token ? `/events?token=${encodeURIComponent(token)}` : '/events';
|
|
const es = new EventSource(url);
|
|
|
|
es.onmessage = (e) => {
|
|
try { prependCard(JSON.parse(e.data)); } catch {}
|
|
};
|
|
|
|
// EventSource reconnects automatically on error; nothing else to do.
|
|
return es;
|
|
}
|
|
|
|
async function fetchFeed() {
|
|
const start = Date.now();
|
|
document.getElementById('status').textContent = 'Loading…';
|
|
try {
|
|
const res = await apiFetch(`/feed?user=${currentUser}&limit=7`);
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
const items = data.items || [];
|
|
const feed = document.getElementById('feed');
|
|
feed.innerHTML = '';
|
|
if (items.length === 0) {
|
|
feed.innerHTML = '<div class="loading">No items yet — try reading some articles!</div>';
|
|
} else {
|
|
items.forEach(item => feed.appendChild(makeCard(item)));
|
|
}
|
|
const ms = Date.now() - start;
|
|
document.getElementById('status').textContent = `${items.length} items in ${ms}ms`;
|
|
} catch (e) {
|
|
document.getElementById('status').textContent = 'Error: ' + e.message;
|
|
}
|
|
}
|
|
|
|
async function loadItemMeta() {
|
|
try {
|
|
const res = await apiFetch('/items');
|
|
if (!res) return;
|
|
const items = await res.json();
|
|
items.forEach(item => { itemMeta[item.id] = item; });
|
|
} catch (e) {
|
|
console.warn('Failed to load item metadata', e);
|
|
}
|
|
}
|
|
|
|
document.getElementById('user-select').addEventListener('change', e => {
|
|
currentUser = parseInt(e.target.value, 10);
|
|
dwellActive = false;
|
|
fetchFeed();
|
|
fetchPrefs();
|
|
});
|
|
|
|
// Initial load
|
|
updateTagPrefsBar();
|
|
loadItemMeta().then(() => {
|
|
fetchFeed();
|
|
fetchPrefs();
|
|
});
|
|
|
|
scheduleRefresh();
|
|
connectSSE();
|
|
pollDiscoveryStatus();
|
|
</script>
|
|
</body>
|
|
</html>
|