Files
upbeatBytes/frontend/src/routes/account/+page.svelte
T
thejayman77 427210ac3e User feedback + expanded privacy-respecting admin stats
Feedback:
- feedback table; POST /api/feedback (anonymous-ok, optional category/email,
  honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback.
- Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop
  header, the mobile top bar (logo moves left), the footer, and /account. Feedback
  section in /admin.

Stats (additive, same privacy model — no IP/UA/referrer/raw terms):
- Event vocab: summary_viewed (fired on /a load), full_story (card → source),
  not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace,
  paywalled_source_open. Card title/image opens /a (no double-count); history
  records via keepalive so it survives the nav.
- Dashboard: Accounts card (counts only), reading funnel (summary→source rate),
  emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics
  deferred to a future monitoring dashboard.) 131 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:58:49 +00:00

165 lines
7.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js';
import { openFeedback } from '$lib/feedback.svelte.js';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
let section = $derived($page.url.searchParams.get('section') || 'profile');
let historyItems = $derived(auth.user ? history.server : history.device);
let savedItems = $state([]);
let savedReady = $state(false);
const SECTIONS = [
{ key: 'profile', label: 'Profile' },
{ key: 'saved', label: 'Saved' },
{ key: 'history', label: 'History' },
{ key: 'boundaries', label: 'Boundaries' },
];
onMount(async () => {
if (!auth.ready) await refresh();
initPrefs();
initHistory();
if (auth.user) loadServerHistory();
});
// Load the saved grid when entering that section while signed in.
$effect(() => {
if (section === 'saved' && auth.user && !savedReady) {
savedReady = true;
getJSON('/api/saved').then((r) => (savedItems = r.items)).catch(() => {});
}
});
let savedShown = $derived(savedItems.filter((a) => savedIds.has(a.id)));
</script>
<header class="bar">
<div class="container inner">
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
<div class="baractions">
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
</button>
<a class="back" href="/">← Back</a>
</div>
</div>
</header>
<main class="container page">
<h1>You</h1>
<nav class="tabs" aria-label="Account sections">
{#each SECTIONS as s (s.key)}
<a href={'/account?section=' + s.key} class:active={section === s.key}>{s.label}</a>
{/each}
{#if auth.user?.is_admin}<a href="/admin" class="admin">Admin dashboard</a>{/if}
</nav>
<div class="content">
{#if section === 'boundaries'}
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
{:else if section === 'history'}
<section class="panel">
<div class="phead">
<h2>History</h2>
{#if historyItems.length}<button class="link" onclick={clearAll}>Clear all</button>{/if}
</div>
<p class="reassure">Stories you've opened or swapped away — so an accidental Replace stays
recoverable. {auth.user ? 'Synced across your devices.' : 'Kept on this device.'}</p>
{#if historyItems.length}
<ul class="hist">
{#each historyItems as a (a.id)}
<li>
<a href={a.url} target="_blank" rel="noopener" onclick={() => track('full_story', a.id)}>{a.title}</a>
<span class="hsrc">{a.source}</span>
<button class="hx" aria-label="Remove" onclick={() => removeOne(a.id)}>×</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing yet — stories you open or swap away will appear here.</p>
{/if}
</section>
{:else if section === 'saved'}
{#if !auth.user}
<p class="gate">Sign in to save articles and find them here.</p>
{:else if savedShown.length}
<div class="grid">
{#each savedShown as a (a.id)}
<ArticleCard article={a} onview={record} />
{/each}
</div>
{:else if savedReady}
<p class="empty">Nothing saved yet — tap <strong>Save</strong> on a story to keep it here.</p>
{:else}
<p class="muted">Loading…</p>
{/if}
{:else}
<!-- profile -->
{#if auth.user}
<AccountPanel onclose={() => {}} />
{:else}
<p class="gate">Sign in from the home page to manage your profile, saved articles, and devices.</p>
{/if}
{/if}
</div>
</main>
<style>
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; }
.baractions { display: flex; align-items: center; gap: 14px; }
.baractions .fb { background: none; border: none; color: var(--accent-deep); cursor: pointer; display: inline-flex; padding: 2px; }
.page { padding: 20px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 16px; }
/* Desktop: sidebar to the left of the content. Mobile: a top tab strip. */
.tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 22px; }
.tabs a {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
}
.tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
.tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.tabs a.admin { margin-left: auto; border-color: var(--accent); color: var(--accent-deep); }
@media (min-width: 760px) {
.page { display: grid; grid-template-columns: 180px 1fr; column-gap: 32px; align-items: start; }
h1 { grid-column: 1 / -1; }
.tabs { flex-direction: column; gap: 4px; position: sticky; top: 80px; }
.tabs a { text-align: left; border-color: transparent; }
.tabs a.admin { margin-left: 0; margin-top: 10px; }
}
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px; }
.phead { display: flex; align-items: baseline; justify-content: space-between; }
.phead h2 { font-size: 1.3rem; }
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
.hist { list-style: none; margin: 0; padding: 0; }
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
.hist li:last-child { border-bottom: none; }
.hist a { color: var(--ink); }
.hist a:hover { color: var(--accent-deep); }
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
.hx { background: none; border: none; color: var(--muted); font-size: 1.15rem; line-height: 1; cursor: pointer; padding: 0 2px; }
.hx:hover { color: var(--accent-deep); }
.empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.9rem; }
.muted { color: var(--muted); }
.gate { color: var(--muted); font-size: 1rem; padding: 8px 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 18px; }
</style>