User avatar (Google picture), avatar in mobile You tab, /account page

- Capture the Google profile picture (picture claim) into users.avatar_url; an
  Avatar component shows it, falling back to the initial. Used in the desktop
  header and the mobile "You" tab (which now shows the user when signed in).
- Move account/settings to its own route /account (robust + scrolls to top),
  reached by the desktop avatar and the mobile You tab; drop the inline "You"
  sheet. AccountPanel gains a Sign out action; the page links to Saved/History/
  Boundaries via home intent params (?view= / ?open=).
- db: users.avatar_url (schema + idempotent migration). 118 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 14:41:43 +00:00
parent bb008cfaa5
commit 15728c3bcb
11 changed files with 168 additions and 87 deletions
@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { getJSON, postJSON, delJSON } from '$lib/api.js';
import { clearLocal } from '$lib/auth.svelte.js';
import { clearLocal, logout as authLogout } from '$lib/auth.svelte.js';
let { onclose } = $props();
let info = $state(null);
@@ -19,6 +19,11 @@
}
});
async function signOut() {
await authLogout();
onclose?.();
}
async function logoutEverywhere() {
busy = 'logout';
try {
@@ -64,6 +69,7 @@
<div class="row"><span class="k">Active sessions</span><span class="v">{info.sessions}</span></div>
<div class="actions">
<button class="btn" onclick={signOut}>Sign out</button>
<a class="btn" href="/api/account/export">Export my data</a>
<button class="btn" onclick={logoutEverywhere} disabled={busy === 'logout'}>
{busy === 'logout' ? 'Signing out…' : 'Sign out everywhere'}
+41
View File
@@ -0,0 +1,41 @@
<script>
let { user, size = 30 } = $props();
let initial = $derived(((user?.display_name || user?.email || '?').trim()[0] || '?').toUpperCase());
let failed = $state(false);
// Reset if the picture URL changes (or a different user signs in).
$effect(() => {
void user?.avatar_url;
failed = false;
});
</script>
{#if user?.avatar_url && !failed}
<img
class="av"
src={user.avatar_url}
alt=""
referrerpolicy="no-referrer"
style="width:{size}px;height:{size}px"
onerror={() => (failed = true)}
/>
{:else}
<span class="av init" style="width:{size}px;height:{size}px;font-size:{size * 0.42}px">{initial}</span>
{/if}
<style>
.av {
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
object-fit: cover;
flex: none;
}
.init {
background: var(--accent);
color: #fff;
font-weight: 600;
font-family: var(--label);
line-height: 1;
}
</style>
+8 -3
View File
@@ -1,7 +1,8 @@
<script>
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
// discovery, You = personal controls (Boundaries, History).
let { active = 'today', onToday, onBrowse, onYou } = $props();
// discovery, You = account + personal controls (shows the user's avatar in).
import Avatar from './Avatar.svelte';
let { active = 'today', onToday, onBrowse, onYou, user = null } = $props();
</script>
<nav class="bottomnav" aria-label="Primary">
@@ -14,7 +15,11 @@
<span>Browse</span>
</button>
<button class:active={active === 'you'} onclick={onYou}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M5 20c0-3.6 3.1-5.5 7-5.5s7 1.9 7 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
{#if user}
<Avatar {user} size={23} />
{:else}
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M5 20c0-3.6 3.1-5.5 7-5.5s7 1.9 7 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
{/if}
<span>You</span>
</button>
</nav>
+3 -9
View File
@@ -1,6 +1,6 @@
<script>
import Avatar from './Avatar.svelte';
let { onBoundaries, onHistory, onaccount, user = null, filtersOn = false } = $props();
let initial = $derived((user?.display_name || user?.email || '?').trim()[0].toUpperCase());
</script>
<header class="appbar">
@@ -23,7 +23,7 @@
</button>
{#if user}
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
<span class="avatar">{initial}</span>
<Avatar {user} size={30} />
</button>
{:else}
<button class="signin" onclick={onaccount}>Sign in</button>
@@ -59,13 +59,7 @@
.utils button.on { color: var(--accent-deep); }
.utils .signin { border-color: var(--line); color: var(--accent-deep); }
.utils .signin:hover { background: var(--accent-soft); }
.acct { padding: 4px; }
.avatar {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 999px;
background: var(--accent); color: #fff; font-weight: 600; font-size: 0.8rem;
font-family: var(--label);
}
.acct { padding: 4px; display: inline-flex; }
/* On phones the utilities live in the bottom tab bar ("You") instead. */
@media (max-width: 720px) {
+14 -55
View File
@@ -1,5 +1,6 @@
<script>
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { getJSON, postJSON, putJSON, delJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
@@ -8,8 +9,7 @@
import ArticleCard from '$lib/components/ArticleCard.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import { auth, savedIds, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
import { auth, savedIds, refresh as refreshAuth } from '$lib/auth.svelte.js';
let moods = $state([]);
let topics = $state([]);
@@ -21,21 +21,14 @@
let userPrefs = $state(P.blank());
let showBoundaries = $state(false);
let showHistory = $state(false);
let showYou = $state(false); // mobile "You" sheet
let showSignIn = $state(false);
let showAccount = $state(false);
// Account/settings is its own page (/account) now — robust + scrolls to top.
function openAccount() {
showYou = false;
if (auth.user) showYou = true; // account lives in the You sheet
if (auth.user) goto('/account');
else showSignIn = true;
}
async function signOut() {
await authLogout();
showYou = false;
}
function openHistory() {
showYou = false;
showHistory = true;
loadServerHistory();
}
@@ -164,7 +157,7 @@
? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '')
);
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
// The hero is the only image slot. Some sources hotlink-protect their images
// (e.g. Guardian → 401), so if the lead's image won't load, promote the next
@@ -216,7 +209,6 @@
async function select(key, fresh = false) {
selected = key;
showYou = false;
error = '';
try {
if (key === 'today') {
@@ -299,7 +291,6 @@
}
function browse() {
showYou = false;
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
if (selected !== 'today') select('today').then(go);
else go();
@@ -318,7 +309,14 @@
// isn't, the Explore-by-family section simply stays hidden and cards fall
// back to the topic pill — the rest of the page works unchanged.
try { families = await getJSON('/api/families'); } catch { families = []; }
await select('today');
// Intent from the /account quick links (Saved / History / Boundaries).
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
const open = params.get('open');
await select(view === 'saved' ? 'saved' : 'today');
if (open === 'history') openHistory();
if (open === 'boundaries') showBoundaries = true;
if (view || open) window.history.replaceState({}, '', '/'); // tidy the URL
} catch (e) {
error = 'Could not reach Upbeat Bytes.';
}
@@ -376,36 +374,6 @@
</section>
{/if}
{#if showYou}
<section class="panel rise youmenu">
<div class="phead"><h2>You</h2><button class="close" onclick={() => (showYou = false)}>done</button></div>
{#if auth.user}
<div class="acctline">Signed in as <strong>{auth.user.email}</strong></div>
<button class="yourow" onclick={() => { showYou = false; select('saved'); }}>
<span>Saved</span>{#if savedIds.size}<span class="dot">{savedIds.size}</span>{/if}
</button>
{/if}
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
</button>
<button class="yourow" onclick={openHistory}>
<span>History</span>{#if historyItems.length}<span class="dot">{historyItems.length}</span>{/if}
</button>
{#if auth.user}
<button class="yourow" onclick={() => { showYou = false; showAccount = true; }}><span>Account</span></button>
<button class="yourow" onclick={signOut}><span>Sign out</span></button>
{:else}
<button class="yourow" onclick={() => { showYou = false; showSignIn = true; }}>
<span>Sign in</span><span class="dot">save & sync</span>
</button>
{/if}
</section>
{/if}
{#if showAccount}
<AccountPanel onclose={() => (showAccount = false)} />
{/if}
{#if notice}<p class="notice rise">{notice}</p>{/if}
{#if loading}
@@ -478,7 +446,7 @@
{/if}
</main>
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={() => (showYou = !showYou)} />
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
@@ -531,15 +499,6 @@
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; }
.reset:hover { color: var(--accent-deep); }
.youmenu .yourow {
width: 100%; display: flex; align-items: center; justify-content: space-between;
background: none; border: none; border-bottom: 1px solid var(--line);
padding: 14px 2px; font-size: 1rem; color: var(--ink); cursor: pointer; text-align: left;
}
.youmenu .yourow:last-child { border-bottom: none; }
.youmenu .dot { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 1px 9px; font-size: 0.78rem; }
.youmenu .acctline { color: var(--muted); font-size: 0.85rem; padding: 4px 2px 10px; border-bottom: 1px solid var(--line); }
.notice {
text-align: center; color: var(--accent-deep); background: var(--accent-soft);
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content; font-size: 0.86rem;
+56
View File
@@ -0,0 +1,56 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import { auth, refresh } from '$lib/auth.svelte.js';
onMount(() => {
if (!auth.ready) refresh();
});
// Once auth resolves, send anonymous visitors back home.
$effect(() => {
if (auth.ready && !auth.user) goto('/', { replaceState: true });
});
</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>
<a class="back" href="/">← Back</a>
</div>
</header>
<main class="container page">
{#if auth.user}
<h1>You</h1>
<nav class="quick">
<a href="/?view=saved">Saved</a>
<a href="/?open=history">History</a>
<a href="/?open=boundaries">Boundaries</a>
</nav>
<AccountPanel onclose={() => goto('/')} />
{/if}
</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; width: auto; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; }
.page { padding: 22px 20px 60px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 8px 0 14px; }
.quick { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.quick a {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
}
.quick a:hover { border-color: var(--accent); color: var(--accent-deep); }
</style>