tidaldb/applications/forage/server/static/index.html
2026-02-23 22:41:16 -07:00

494 lines
20 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; }
/* ── Onboarding overlay ── */
#onboard-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 150; align-items: center; justify-content: center; }
#onboard-overlay.show { display: flex; }
#onboard-box { background: #1a1a1a; border: 1px solid #333; border-radius: 14px; padding: 32px; width: min(480px, 90vw); display: flex; flex-direction: column; gap: 20px; }
#onboard-box h2 { font-size: 1.2rem; color: #fff; }
#onboard-box p { font-size: 0.85rem; color: #888; line-height: 1.5; }
#onboard-chips { display: flex; flex-wrap: wrap; gap: 10px; }
.onboard-chip {
font-size: 0.82rem; font-weight: 600; padding: 7px 16px; border-radius: 999px;
border: 1px solid #333; background: #222; color: #aaa; cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
text-transform: capitalize;
}
.onboard-chip.selected { background: #0d2e1a; border-color: #4ade80; color: #4ade80; }
#onboard-submit { background: #1a2e1a; border: 1px solid #4ade80; color: #4ade80; padding: 10px; border-radius: 8px; font-size: 0.88rem; cursor: pointer; opacity: 0.4; pointer-events: none; transition: opacity 0.2s; }
#onboard-submit.ready { opacity: 1; pointer-events: auto; }
#onboard-submit:hover.ready { background: #223e22; }
#onboard-hint { font-size: 0.76rem; color: #555; }
</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="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>
<!-- Onboarding overlay: shown to cold-start users -->
<div id="onboard-overlay">
<div id="onboard-box">
<h2>What do you want to read?</h2>
<p>Pick one or more topics to get started. Forage will personalise your feed as you read.</p>
<div id="onboard-chips"></div>
<div id="onboard-hint">Select at least one topic</div>
<button id="onboard-submit">Start reading →</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();
});
// ── Onboarding overlay ──────────────────────────────────────────────────────
const ONBOARD_CATEGORIES = [
'technology', 'science', 'jazz', 'travel',
'cooking', 'design', 'history', 'health',
];
function isOnboarded(userId) {
try {
const ids = JSON.parse(localStorage.getItem('forage_onboarded_users') || '[]');
return ids.includes(userId);
} catch { return false; }
}
function markOnboarded(userId) {
try {
const ids = JSON.parse(localStorage.getItem('forage_onboarded_users') || '[]');
if (!ids.includes(userId)) ids.push(userId);
localStorage.setItem('forage_onboarded_users', JSON.stringify(ids));
} catch {}
}
function buildOnboardChips() {
const container = document.getElementById('onboard-chips');
container.innerHTML = '';
ONBOARD_CATEGORIES.forEach(cat => {
const chip = document.createElement('button');
chip.className = 'onboard-chip';
chip.textContent = cat;
chip.dataset.cat = cat;
chip.addEventListener('click', () => {
chip.classList.toggle('selected');
updateOnboardSubmit();
});
container.appendChild(chip);
});
}
function updateOnboardSubmit() {
const selected = document.querySelectorAll('.onboard-chip.selected');
const btn = document.getElementById('onboard-submit');
const hint = document.getElementById('onboard-hint');
if (selected.length > 0) {
btn.classList.add('ready');
hint.textContent = `${selected.length} topic${selected.length > 1 ? 's' : ''} selected`;
} else {
btn.classList.remove('ready');
hint.textContent = 'Select at least one topic';
}
}
function showOnboardOverlay() {
buildOnboardChips();
document.getElementById('onboard-overlay').classList.add('show');
}
function hideOnboardOverlay() {
document.getElementById('onboard-overlay').classList.remove('show');
}
document.getElementById('onboard-submit').addEventListener('click', async () => {
const selected = Array.from(document.querySelectorAll('.onboard-chip.selected'))
.map(c => c.dataset.cat);
if (selected.length === 0) return;
const res = await apiFetch('/onboard', {
method: 'POST',
body: JSON.stringify({ user_id: currentUser, categories: selected }),
});
if (!res) return; // 401 handled by apiFetch
markOnboarded(currentUser);
hideOnboardOverlay();
fetchFeed();
fetchPrefs();
});
// ── 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' };
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' };
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(() => {});
}
// ── 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 — read some articles!';
el.classList.remove('active');
// Show onboarding if this user hasn't been through it
if (!isOnboarded(currentUser)) {
showOnboardOverlay();
}
}
} 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 || '#';
card.innerHTML = `
<span class="score">score: ${(item.score || 0).toFixed(3)}</span>
<div class="card-meta">
<span class="chip ${categoryClass(item.category)}">${item.category}</span>
<span class="chip ${labelClass(item.label)}">${labelText(item.label)}</span>
<span class="reading-time">${item.reading_time_min || meta.reading_time_min || '?'} min</span>
</div>
<div class="card-title">${item.title}</div>
<div class="card-source">${item.source}</div>
<div class="card-desc">${item.description || meta.description || ''}</div>
<div class="card-actions">
<button class="btn btn-skip">Skip</button>
<button class="btn btn-save">Save</button>
<button class="btn btn-share">Share</button>
</div>
`;
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;
}
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
loadItemMeta().then(() => {
fetchFeed();
fetchPrefs();
});
scheduleRefresh();
</script>
</body>
</html>