Files
upbeatBytes/frontend/src/routes/account/+page.svelte
T
thejayman77 86a6bd3b45 Card + article-page polish
* Placeholder: bold, slightly larger initial letter on the topic word; make
  health (teal) and environment (leaf green) clearly distinct and show more hue
  in the deepened word so they're easy to tell apart.
* Article page: the source name was chopped to its first word ("Read the full
  story at The") — use the full publisher name; open the source link in a new
  tab so upbeatbytes.com stays put.
* Use the new SVG back arrow on the account and admin top bars (matching the
  article page) instead of the old "←" glyph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:05:32 -04:00

185 lines
8.1 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 LanePicker from '$lib/components/LanePicker.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);
let lanePool = $state(null);
const SECTIONS = [
{ key: 'profile', label: 'Profile' },
{ key: 'saved', label: 'Saved' },
{ key: 'history', label: 'History' },
{ key: 'lanes', label: 'Lanes' },
{ key: 'boundaries', label: 'Boundaries' },
];
onMount(async () => {
if (!auth.ready) await refresh();
initPrefs();
initHistory();
if (auth.user) loadServerHistory();
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
});
let pinnedLaneKeys = $derived(
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
);
function saveLanes(keys) {
prefs.data.lanes = keys;
persistPrefs();
}
// 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="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>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 === 'lanes'}
{#if lanePool}
<LanePicker inline pool={lanePool} selected={pinnedLaneKeys} onsave={saveLanes} />
{:else}
<p class="muted">Loading…</p>
{/if}
{:else 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; display: inline-flex; align-items: center; gap: 5px; }
.back svg { width: 17px; height: 17px; display: block; }
.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>