494 lines
20 KiB
HTML
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>
|