Consolidate Boundaries + History under the account; sectioned /account
The inline Boundaries/History panels lived on the home page, so opening them while scrolled left you stranded. Move everything "yours" behind the account icon: - Home header slims to: Saved (opens a right-side flyout, signed-in) · shield (Boundaries indicator — filled when active — linking to the Boundaries section) · avatar. The inline panels + the home "saved" view are gone. - /account is now a sectioned hub (left sidebar on desktop, top tabs on mobile), OPEN TO EVERYONE with each section self-gating: Profile (sign-in), Saved (sign-in), History (device/account), Boundaries (device/account), Admin (admins). This keeps Boundaries/History usable without an account (they're device-local) while consolidating the UI — and every section loads at the top, fixing the scroll bug. - Lift Calm Filters and History into shared stores (prefs.svelte.js, history.svelte.js) so the home feed (applies/records) and the account page (edits/manages) share one source of truth. New SavedFlyout component. Card boundary actions only render when a handler is provided. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,9 +155,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
{#if article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
|
||||
{#if article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
|
||||
{#if article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
|
||||
{#if onaction && article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
|
||||
{#if onaction && article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
|
||||
{#if onaction && article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -42,9 +42,9 @@
|
||||
<section class="panel rise">
|
||||
<div class="head">
|
||||
<h2>Your boundaries</h2>
|
||||
<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>
|
||||
{#if onclose}<button class="close" onclick={() => onclose?.()} aria-label="close">done</button>{/if}
|
||||
</div>
|
||||
<p class="reassure">Kept on this device. Nothing leaves it, and there's no account.</p>
|
||||
<p class="reassure">Your calm filters — kept on this device, and synced to your account when you're signed in.</p>
|
||||
|
||||
<div class="group">
|
||||
<label class="label" for="avoid">Avoid words or phrases</label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import Avatar from './Avatar.svelte';
|
||||
let { onBoundaries, onHistory, onaccount, user = null, filtersOn = false } = $props();
|
||||
let { onSaved, onaccount, user = null, boundariesActive = false } = $props();
|
||||
</script>
|
||||
|
||||
<header class="appbar">
|
||||
@@ -10,17 +10,19 @@
|
||||
</a>
|
||||
|
||||
<nav class="utils" aria-label="Your controls">
|
||||
<button class:on={filtersOn} onclick={onBoundaries} title="Your boundaries">
|
||||
{#if user}
|
||||
<button class="util" onclick={onSaved} title="Saved articles">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4z"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
<span>Saved</span>
|
||||
</button>
|
||||
{/if}
|
||||
<a class="util shield" class:on={boundariesActive} href="/account?section=boundaries"
|
||||
title={boundariesActive ? 'Boundaries are on' : 'Your boundaries'}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||
<span>Boundaries</span>
|
||||
</button>
|
||||
<button onclick={onHistory} title="What you've seen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" /><path d="M12 7v5l3.5 2" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||
<span>History</span>
|
||||
</button>
|
||||
fill={boundariesActive ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linejoin="round" /></svg>
|
||||
</a>
|
||||
{#if user}
|
||||
<button class="acct" onclick={onaccount} title={user.email} aria-label="Your account">
|
||||
<Avatar {user} size={30} />
|
||||
@@ -39,29 +41,30 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
/* a whisper of warmth under the bar */
|
||||
box-shadow: 0 1px 0 rgba(40, 38, 28, 0.02);
|
||||
}
|
||||
.bar { display: flex; align-items: center; justify-content: space-between; height: 78px; }
|
||||
|
||||
.brand { display: inline-flex; align-items: center; }
|
||||
.logo { height: 54px; width: auto; display: block; }
|
||||
|
||||
.utils { display: flex; gap: 6px; }
|
||||
.utils button {
|
||||
.utils { display: flex; align-items: center; gap: 4px; }
|
||||
.utils .util {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: none; border: 1px solid transparent; border-radius: 999px;
|
||||
padding: 7px 13px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
|
||||
padding: 7px 12px; color: var(--muted); font-size: 0.86rem; cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
.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; display: inline-flex; }
|
||||
.utils .util svg { width: 18px; height: 18px; }
|
||||
.utils .util:hover { background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.shield.on { color: var(--accent); }
|
||||
.acct { background: none; border: none; padding: 4px; display: inline-flex; cursor: pointer; }
|
||||
.signin {
|
||||
background: none; border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 7px 13px; color: var(--accent-deep); font-size: 0.86rem; cursor: pointer;
|
||||
}
|
||||
.signin:hover { background: var(--accent-soft); }
|
||||
|
||||
/* On phones the utilities live in the bottom tab bar ("You") instead. */
|
||||
/* On phones the bottom tab bar handles navigation; keep the bar to the logo. */
|
||||
@media (max-width: 720px) {
|
||||
.bar { height: 66px; justify-content: center; }
|
||||
.utils { display: none; }
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { savedIds, toggleSave } from '$lib/auth.svelte.js';
|
||||
import { track } from '$lib/analytics.js';
|
||||
|
||||
let { onclose } = $props();
|
||||
let items = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
items = (await getJSON('/api/saved')).items;
|
||||
} catch {
|
||||
/* not signed in / transient */
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter by the live saved set so an unsave removes it immediately.
|
||||
let shown = $derived(items.filter((a) => savedIds.has(a.id)));
|
||||
|
||||
function onkey(e) {
|
||||
if (e.key === 'Escape') onclose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<div class="overlay" onclick={onclose} role="presentation">
|
||||
<aside class="drawer" onclick={(e) => e.stopPropagation()} aria-label="Saved articles">
|
||||
<div class="head">
|
||||
<h2>Saved</h2>
|
||||
<button class="x" onclick={onclose} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if shown.length}
|
||||
<ul>
|
||||
{#each shown as a (a.id)}
|
||||
<li>
|
||||
<a class="t" href={a.url} target="_blank" rel="noopener" onclick={() => track('open', a.id)}>{a.title}</a>
|
||||
<div class="meta">
|
||||
<span class="src">{a.source}</span>
|
||||
<button class="rm" onclick={() => toggleSave(a.id)}>Remove</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a class="all" href="/account?section=saved" onclick={onclose}>Open Saved in account →</a>
|
||||
{:else}
|
||||
<p class="muted">Nothing saved yet — tap <strong>Save</strong> on a story to keep it here.</p>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 22, 38, 0.32);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.drawer {
|
||||
background: var(--surface);
|
||||
width: min(380px, 100%);
|
||||
height: 100%;
|
||||
box-shadow: -10px 0 30px rgba(40, 38, 28, 0.12);
|
||||
padding: 20px 22px;
|
||||
overflow-y: auto;
|
||||
animation: slide 0.2s ease both;
|
||||
}
|
||||
@keyframes slide { from { transform: translateX(20px); opacity: 0.4; } to { transform: none; opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .drawer { animation: none; } }
|
||||
.head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
|
||||
.head h2 { font-size: 1.4rem; }
|
||||
.x { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--muted); cursor: pointer; }
|
||||
.muted { color: var(--muted); }
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
li { padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
li:last-of-type { border-bottom: none; }
|
||||
.t { color: var(--ink); font-weight: 600; font-size: 0.95rem; display: block; }
|
||||
.t:hover { color: var(--accent-deep); }
|
||||
.meta { display: flex; align-items: center; justify-content: space-between; margin-top: 5px; }
|
||||
.src { color: var(--muted); font-size: 0.78rem; }
|
||||
.rm { background: none; border: none; color: var(--muted); font-size: 0.78rem; text-decoration: underline; cursor: pointer; }
|
||||
.rm:hover { color: var(--accent-deep); }
|
||||
.all { display: inline-block; margin-top: 16px; color: var(--accent-deep); font-size: 0.88rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
// Reactive history, shared across the home feed (which RECORDS opens/replaces)
|
||||
// and the account page (which DISPLAYS + manages). Device-local always; synced to
|
||||
// the account when signed in. Only deliberate events live here — opened or
|
||||
// replaced-away articles — never everything merely shown.
|
||||
import { auth } from './auth.svelte.js';
|
||||
import { getJSON, postJSON, delJSON } from './api.js';
|
||||
|
||||
const KEY = 'goodnews:history';
|
||||
const CAP = 200;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(KEY));
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function save(arr) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(arr.slice(0, CAP)));
|
||||
} catch {
|
||||
/* private mode / quota */
|
||||
}
|
||||
}
|
||||
|
||||
export const history = $state({ device: [], server: [], ready: false });
|
||||
|
||||
export function initHistory() {
|
||||
if (history.ready) return;
|
||||
history.device = load();
|
||||
history.ready = true;
|
||||
}
|
||||
|
||||
export function deviceIds() {
|
||||
return history.device.map((a) => a.id);
|
||||
}
|
||||
|
||||
export function record(article) {
|
||||
if (!article) return;
|
||||
if (!history.device.some((h) => h.id === article.id)) {
|
||||
history.device = [article, ...history.device].slice(0, CAP);
|
||||
save(history.device);
|
||||
}
|
||||
if (auth.user) {
|
||||
if (!history.server.some((h) => h.id === article.id)) {
|
||||
history.server = [article, ...history.server];
|
||||
}
|
||||
postJSON('/api/history', { ids: [article.id] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function removeOne(id) {
|
||||
history.device = history.device.filter((h) => h.id !== id);
|
||||
history.server = history.server.filter((h) => h.id !== id);
|
||||
save(history.device);
|
||||
if (auth.user) delJSON(`/api/history/${id}`).catch(() => {});
|
||||
}
|
||||
|
||||
export function clearAll() {
|
||||
history.device = [];
|
||||
history.server = [];
|
||||
save([]);
|
||||
if (auth.user) delJSON('/api/history').catch(() => {});
|
||||
}
|
||||
|
||||
export async function loadServerHistory() {
|
||||
if (!auth.user) {
|
||||
history.server = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
history.server = (await getJSON('/api/history')).items;
|
||||
} catch {
|
||||
/* leave as-is */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Reactive Calm Filters (Boundaries), shared across the home feed (which applies
|
||||
// them) and the account page (which edits them). One source of truth.
|
||||
import * as P from './prefs.js';
|
||||
import { auth } from './auth.svelte.js';
|
||||
import { getJSON, putJSON } from './api.js';
|
||||
|
||||
export const prefs = $state({ data: P.blank(), ready: false });
|
||||
|
||||
export function initPrefs() {
|
||||
if (prefs.ready) return;
|
||||
prefs.data = P.load();
|
||||
prefs.ready = true;
|
||||
}
|
||||
|
||||
export function active() {
|
||||
return P.active(prefs.data);
|
||||
}
|
||||
|
||||
export function persistPrefs() {
|
||||
P.save(prefs.data);
|
||||
if (auth.user) putJSON('/api/prefs', { prefs: prefs.data }).catch(() => {});
|
||||
}
|
||||
|
||||
// Card actions ("Not today" / "Less like this" / "Hide topic").
|
||||
export function applyPrefAction(kind, value) {
|
||||
P[kind]?.(prefs.data, value);
|
||||
prefs.data = { ...prefs.data };
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// On sign-in: adopt the account's prefs if present, else seed them from this device.
|
||||
export async function syncPrefsOnLogin() {
|
||||
try {
|
||||
const res = await getJSON('/api/prefs');
|
||||
if (res && res.prefs) {
|
||||
const incoming = Object.assign(P.blank(), res.prefs);
|
||||
if (JSON.stringify(incoming) !== JSON.stringify(prefs.data)) {
|
||||
prefs.data = incoming;
|
||||
P.save(prefs.data);
|
||||
}
|
||||
} else {
|
||||
await putJSON('/api/prefs', { prefs: prefs.data });
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user