diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 5649389..ff5334e 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -3,3 +3,23 @@ export async function getJSON(url) {
if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
return r.json();
}
+
+export async function postJSON(url, body) {
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body ?? {}),
+ credentials: 'same-origin', // send/receive the session cookie
+ });
+ if (!r.ok) {
+ let msg = r.statusText;
+ try {
+ const j = await r.json();
+ if (j && j.detail) msg = j.detail;
+ } catch {
+ /* non-JSON error body */
+ }
+ throw new Error(msg);
+ }
+ return r.json();
+}
diff --git a/frontend/src/lib/auth.svelte.js b/frontend/src/lib/auth.svelte.js
new file mode 100644
index 0000000..fdefa6a
--- /dev/null
+++ b/frontend/src/lib/auth.svelte.js
@@ -0,0 +1,35 @@
+// Shared, reactive auth state (Svelte 5 runes in a module). Components read
+// `auth.user` and it stays reactive across the app.
+import { getJSON, postJSON } from './api.js';
+
+export const auth = $state({ user: null, ready: false });
+
+export async function refresh() {
+ try {
+ auth.user = await getJSON('/api/auth/me');
+ } catch {
+ auth.user = null;
+ } finally {
+ auth.ready = true;
+ }
+}
+
+// Request a magic link. Reply is intentionally identical for any address.
+export function startEmail(email) {
+ return postJSON('/api/auth/email/start', { email });
+}
+
+// Exchange a magic-link token for a session (cookie set server-side).
+export async function verifyToken(token) {
+ const res = await postJSON('/api/auth/email/verify', { token });
+ auth.user = res.user;
+ return res;
+}
+
+export async function logout() {
+ try {
+ await postJSON('/api/auth/logout', {});
+ } finally {
+ auth.user = null;
+ }
+}
diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte
index b49df73..256c86b 100644
--- a/frontend/src/lib/components/Header.svelte
+++ b/frontend/src/lib/components/Header.svelte
@@ -1,5 +1,6 @@
@@ -20,6 +21,13 @@
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
History
+ {#if user}
+
+ {:else}
+
+ {/if}
@@ -49,6 +57,15 @@
.utils button svg { width: 17px; height: 17px; }
.utils button:hover { background: var(--accent-soft); color: var(--accent-deep); }
.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);
+ }
/* On phones the utilities live in the bottom tab bar ("You") instead. */
@media (max-width: 720px) {
diff --git a/frontend/src/lib/components/SignIn.svelte b/frontend/src/lib/components/SignIn.svelte
new file mode 100644
index 0000000..fb8cf2f
--- /dev/null
+++ b/frontend/src/lib/components/SignIn.svelte
@@ -0,0 +1,149 @@
+
+
+
+
e.stopPropagation()}>
+
+
+ {#if status === 'sent'}
+
Check your inbox
+
+ We've sent a one-time sign-in link to {email}. It works once and
+ expires in 15 minutes.
+
+
+ {:else}
+
Sign in to Upbeat Bytes
+
+ Save articles and keep your history across devices. We'll email you a one-time
+ link — no password to remember.
+
+
+
+
+ {#if status === 'error'}
{error}
{/if}
+
+
No account needed to browse — signing in just saves your stuff.
+ {/if}
+
+
+
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index b714dd2..a1c43b0 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -7,6 +7,8 @@
import MoodNav from '$lib/components/MoodNav.svelte';
import ArticleCard from '$lib/components/ArticleCard.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
+ import SignIn from '$lib/components/SignIn.svelte';
+ import { auth, refresh as refreshAuth, logout as authLogout } from '$lib/auth.svelte.js';
let moods = $state([]);
let topics = $state([]);
@@ -19,6 +21,17 @@
let showBoundaries = $state(false);
let showHistory = $state(false);
let showYou = $state(false); // mobile "You" sheet
+ let showSignIn = $state(false);
+
+ function openAccount() {
+ showYou = false;
+ if (auth.user) showYou = true; // account lives in the You sheet
+ else showSignIn = true;
+ }
+ async function signOut() {
+ await authLogout();
+ showYou = false;
+ }
let loading = $state(true);
let error = $state('');
@@ -227,6 +240,7 @@
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
history = P.loadJSON(HISTORY_KEY, []);
+ refreshAuth(); // resolve any existing session (non-blocking)
try {
moods = await getJSON('/api/moods');
topics = (await getJSON('/api/categories')).topics;
@@ -242,7 +256,15 @@
});
- (showBoundaries = !showBoundaries)} onHistory={() => (showHistory = !showHistory)} {filtersOn} />
+ (showBoundaries = !showBoundaries)}
+ onHistory={() => (showHistory = !showHistory)}
+ onaccount={openAccount}
+ user={auth.user}
+ {filtersOn}
+/>
+
+{#if showSignIn} (showSignIn = false)} />{/if}
{#if moods.length}
@@ -278,12 +300,22 @@
{#if showYou}
{/if}
@@ -413,6 +445,7 @@
}
.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);
diff --git a/frontend/src/routes/auth/verify/+page.svelte b/frontend/src/routes/auth/verify/+page.svelte
new file mode 100644
index 0000000..b83bb5c
--- /dev/null
+++ b/frontend/src/routes/auth/verify/+page.svelte
@@ -0,0 +1,54 @@
+
+
+
+ {#if status === 'verifying'}
+ Signing you in…
+ {:else}
+ Couldn't sign you in
+ {error}
+ ← Back to Upbeat Bytes
+ {/if}
+
+
+