Files
upbeatBytes/frontend/src/routes/account/+page.svelte
T
thejayman77 8b44e559e4 Images Phase 3+4: roll tile treatment to Latest, topic/tag feeds, Saved
Carry the brief's uniform card language (compact photo banner or flat topic-
colored placeholder) onto every feed grid — Latest, topic/tag/mood views, and
the account Saved grid — so the whole site is visually consistent. Same quality
gate and uniform sizing; the brief hero remains the single large image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:25:34 -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} thumb 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>