8b44e559e4
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>
185 lines
8.1 KiB
Svelte
185 lines
8.1 KiB
Svelte
<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>
|