8c52582ae3
* Back button on feed views: drilling into a tag or source from a card now remembers where you came from (a small history stack), and a "← Back" appears in the view header to return there — chains of drill-ins included. Top-level nav (rail/bottom bar) resets the history. * Article page: the source name is now a link into that source's in-app feed (/?source=<id>); the SPA reads the param on load and opens the source view (label falls back to the loaded feed's source name). Completes the "cards-only v1" — source is clickable on /a/ too now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
504 lines
20 KiB
Svelte
504 lines
20 KiB
Svelte
<script>
|
|
import { onMount, untrack } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { getJSON, postJSON } from '$lib/api.js';
|
|
import * as P from '$lib/prefs.js';
|
|
import Header from '$lib/components/Header.svelte';
|
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
|
import MoodNav from '$lib/components/MoodNav.svelte';
|
|
import LanePicker from '$lib/components/LanePicker.svelte';
|
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
|
import SignIn from '$lib/components/SignIn.svelte';
|
|
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
|
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
|
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
|
import { trackVisit, track } from '$lib/analytics.js';
|
|
|
|
let moods = $state([]);
|
|
let topics = $state([]);
|
|
let families = $state([]);
|
|
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
|
let showLanes = $state(false);
|
|
let selected = $state('today'); // 'today' | mood | topic | 'tag:<slug>' | 'source:<id>'
|
|
let currentSource = $state(null); // {id, name} for a 'source:<id>' view's header
|
|
let brief = $state(null);
|
|
let heroIdx = $state(0);
|
|
let feed = $state([]);
|
|
let feedDone = $state(false); // no more pages for the current feed view
|
|
let loadingMore = $state(false);
|
|
let showSignIn = $state(false);
|
|
let showSaved = $state(false); // Saved flyout
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let notice = $state('');
|
|
|
|
// Device-local browsing memory (separate from history): seen = "displayed"
|
|
// (so Replace doesn't recycle), dismissed = swapped/avoided this session.
|
|
const SEEN_KEY = 'goodnews:seen';
|
|
const DISMISSED_KEY = 'goodnews:dismissed';
|
|
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
|
|
let seenIds = new Set();
|
|
let dismissed = $state(new Set());
|
|
|
|
function persistSession() {
|
|
P.saveJSON(SEEN_KEY, [...seenIds]);
|
|
P.saveJSON(DISMISSED_KEY, [...dismissed]);
|
|
}
|
|
function markDisplayed(items) {
|
|
let changed = false;
|
|
for (const a of items || []) {
|
|
if (a && !seenIds.has(a.id)) { seenIds.add(a.id); changed = true; }
|
|
}
|
|
if (changed) P.saveJSON(SEEN_KEY, [...seenIds]);
|
|
}
|
|
|
|
function openAccount() {
|
|
if (auth.user) goto('/account');
|
|
else showSignIn = true;
|
|
}
|
|
|
|
// React to sign-in only (untrack the body so browsing doesn't retrigger it).
|
|
$effect(() => {
|
|
const u = auth.user;
|
|
if (u && typeof window !== 'undefined') untrack(() => onLogin(u));
|
|
});
|
|
|
|
async function onLogin(u) {
|
|
const key = 'goodnews:imported:' + u.id;
|
|
if (!localStorage.getItem(key)) {
|
|
try {
|
|
await postJSON('/api/import', { seen: deviceIds(), saved: [] });
|
|
localStorage.setItem(key, '1');
|
|
} catch { /* best-effort */ }
|
|
}
|
|
loadServerHistory();
|
|
await syncPrefsOnLogin(); // adopt account prefs or seed from device
|
|
select(selected, true); // reflect any adopted boundaries in the feed
|
|
}
|
|
|
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
|
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
|
|
|
// Show the brief's date in the VIEWER's local timezone (from its UTC freshness
|
|
// stamp), so everyone sees their own correct date — never "tomorrow."
|
|
function localDateLabel(b) {
|
|
const ts = b?.generated_at;
|
|
if (ts) {
|
|
const d = new Date(ts.replace(' ', 'T') + 'Z');
|
|
if (!isNaN(d)) return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
|
}
|
|
return b?.brief_date ?? '';
|
|
}
|
|
let filtersOn = $derived(prefsActive());
|
|
let currentMood = $derived(moods.find((m) => m.key === selected));
|
|
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
|
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
|
|
let tagFamily = $derived(
|
|
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
|
|
);
|
|
let viewLabel = $derived(
|
|
selected === 'today' ? 'Highlights from Today'
|
|
: selected.startsWith('source:') ? (currentSource?.name ?? feed[0]?.source ?? 'Source')
|
|
: selected === 'latest' ? 'Latest'
|
|
: currentTag ? humanize(currentTag)
|
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
|
);
|
|
let viewSubtitle = $derived(
|
|
selected === 'today' ? localDateLabel(brief)
|
|
: selected.startsWith('source:') ? 'Latest from this source'
|
|
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
|
: currentTag ? (tagFamily?.description ?? '')
|
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
|
);
|
|
let activeTab = $derived(
|
|
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
|
|
);
|
|
|
|
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
|
|
// first, then the reader's chosen lanes (or the default set if they've never
|
|
// customized). Resolve each key to its {label, description} from the pool.
|
|
let laneMap = $derived(
|
|
new Map(
|
|
lanePool
|
|
? [
|
|
...lanePool.pinned.map((p) => [p.key, p]),
|
|
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
|
|
]
|
|
: []
|
|
)
|
|
);
|
|
let pinnedLaneKeys = $derived(
|
|
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
|
);
|
|
let navLanes = $derived(
|
|
lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
|
);
|
|
|
|
function saveLanes(keys) {
|
|
prefs.data.lanes = keys;
|
|
persistPrefs();
|
|
// If the reader unpinned the lane they're viewing, fall back to Highlights.
|
|
// (The pinned Highlights/Latest lanes are never in `keys`, so don't bounce.)
|
|
if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) select('today');
|
|
}
|
|
|
|
// Hero is the only image slot; if its image won't load, promote the next one.
|
|
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
|
|
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
|
|
function heroImageFailed() {
|
|
const items = brief?.items ?? [];
|
|
for (let i = heroIdx + 1; i < items.length; i++) {
|
|
if (items[i]?.image_url) { heroIdx = i; return; }
|
|
}
|
|
}
|
|
|
|
function viewFilter(key = selected) {
|
|
if (key === 'today') return {};
|
|
const m = moods.find((x) => x.key === key);
|
|
if (m) return m.filter ?? {};
|
|
return { include_topics: [key] };
|
|
}
|
|
function mergedParam() {
|
|
return P.param(P.merge(prefs.data, viewFilter()));
|
|
}
|
|
|
|
async function loadToday(fresh) {
|
|
const q = P.param(prefs.data);
|
|
const ex = Array.from(dismissed).join(',');
|
|
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
|
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
|
|
const sameServerBrief =
|
|
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
|
|
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
|
|
const freshById = new Map(fetched.items.map((a) => [a.id, a]));
|
|
const items = view.items.map((it) => freshById.get(it.id) ?? it);
|
|
brief = { ...fetched, items };
|
|
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items });
|
|
} else {
|
|
brief = fetched;
|
|
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
|
|
}
|
|
heroIdx = 0;
|
|
markDisplayed(brief.items);
|
|
}
|
|
|
|
// Build a /api/feed URL for a lane view at a given page offset. 'latest' is
|
|
// the chronological firehose (sort=latest); 'tag:x' browses a grouping tag;
|
|
// anything else resolves through its mood/topic filter.
|
|
const PAGE = 24;
|
|
function feedUrl(key, offset) {
|
|
const ex = Array.from(dismissed).join(',');
|
|
const exq = ex ? `&exclude=${ex}` : '';
|
|
if (key === 'latest') {
|
|
const q = P.param(prefs.data);
|
|
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
|
}
|
|
if (key.startsWith('tag:')) {
|
|
const q = P.param(prefs.data);
|
|
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
|
}
|
|
if (key.startsWith('source:')) {
|
|
// A publication feed: this source's articles, newest first, still
|
|
// accepted/non-duplicate/boundary-filtered.
|
|
const q = P.param(prefs.data);
|
|
return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
|
}
|
|
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
|
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
|
}
|
|
|
|
// Drill into a tag or source view from a card, remembering where we came from
|
|
// so the in-feed Back button can return there (chains of drill-ins too).
|
|
let navStack = $state([]); // [{ key, source }] views to return to
|
|
function drill(key, source = null) {
|
|
navStack = [...navStack, { key: selected, source: currentSource }];
|
|
if (source) currentSource = source;
|
|
select(key);
|
|
}
|
|
function goBack() {
|
|
const prev = navStack[navStack.length - 1];
|
|
if (!prev) return;
|
|
navStack = navStack.slice(0, -1);
|
|
currentSource = prev.source;
|
|
select(prev.key);
|
|
}
|
|
|
|
async function select(key, fresh = false) {
|
|
// Top-level navigation (nav rail / bottom bar) resets the drill-in history;
|
|
// only tag/source drill-ins build it up.
|
|
if (!key.startsWith('tag:') && !key.startsWith('source:')) navStack = [];
|
|
selected = key;
|
|
error = '';
|
|
feedDone = false;
|
|
try {
|
|
if (key === 'today') {
|
|
await loadToday(fresh);
|
|
} else {
|
|
const items = (await getJSON(feedUrl(key, 0))).items;
|
|
feed = items;
|
|
feedDone = items.length < PAGE;
|
|
markDisplayed(feed);
|
|
}
|
|
} catch (e) {
|
|
error = 'Something went quiet — could not reach the feed.';
|
|
}
|
|
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
|
|
// page at the current length and append, de-duping against what's shown.
|
|
async function loadMore() {
|
|
if (loadingMore || feedDone || selected === 'today') return;
|
|
loadingMore = true;
|
|
try {
|
|
const items = (await getJSON(feedUrl(selected, feed.length))).items;
|
|
const have = new Set(feed.map((a) => a.id));
|
|
const fresh = items.filter((a) => !have.has(a.id));
|
|
feed = [...feed, ...fresh];
|
|
feedDone = items.length < PAGE;
|
|
markDisplayed(fresh);
|
|
} catch {
|
|
flash('Could not load more just now.');
|
|
} finally {
|
|
loadingMore = false;
|
|
}
|
|
}
|
|
|
|
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
|
function applyAction(kind, value) {
|
|
applyPrefAction(kind, value); // updates + persists + syncs to account
|
|
if (MIX_EVENT[kind]) track(MIX_EVENT[kind]); // emotional-mix signal (no value stored)
|
|
select(selected, true); // re-filter the feed
|
|
}
|
|
|
|
function flash(msg) {
|
|
notice = msg;
|
|
if (typeof window !== 'undefined') setTimeout(() => (notice = ''), 4000);
|
|
}
|
|
|
|
async function replaceArticle(article) {
|
|
const list = selected === 'today' ? brief?.items : feed;
|
|
if (!list) return;
|
|
const isHero = selected === 'today' && heroArticle?.id === article.id;
|
|
const exclude = Array.from(seenIds).join(',');
|
|
const q = mergedParam();
|
|
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
|
|
let repl;
|
|
try {
|
|
repl = await getJSON(url);
|
|
} catch {
|
|
flash('Could not reach the feed just now.');
|
|
return;
|
|
}
|
|
if (!repl) {
|
|
flash("That's everything fresh for now — nothing new to swap in.");
|
|
track('replace_none');
|
|
return;
|
|
}
|
|
track('replace_used', article.id);
|
|
if (article.paywalled) track('paywall_replace', article.id);
|
|
dismissed.add(article.id);
|
|
seenIds.add(article.id);
|
|
markDisplayed([repl]);
|
|
record(article); // keep the swapped-away story (recoverable)
|
|
persistSession();
|
|
if (selected === 'today') {
|
|
const i = brief.items.findIndex((a) => a.id === article.id);
|
|
if (i >= 0) {
|
|
brief.items[i] = repl;
|
|
brief = { ...brief, items: [...brief.items] };
|
|
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items });
|
|
}
|
|
} else {
|
|
const i = feed.findIndex((a) => a.id === article.id);
|
|
if (i >= 0) {
|
|
feed[i] = repl;
|
|
feed = [...feed];
|
|
}
|
|
}
|
|
}
|
|
|
|
function browse() {
|
|
const go = () => document.getElementById('explore')?.scrollIntoView({ behavior: 'smooth' });
|
|
if (selected !== 'today') select('today').then(go);
|
|
else go();
|
|
}
|
|
|
|
onMount(async () => {
|
|
initPrefs();
|
|
initHistory();
|
|
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
|
|
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
|
refreshAuth();
|
|
trackVisit();
|
|
try {
|
|
moods = await getJSON('/api/moods');
|
|
topics = (await getJSON('/api/categories')).topics;
|
|
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
|
|
try { families = await getJSON('/api/families'); } catch { families = []; }
|
|
// Deep link from an article page's source name (/?source=<id>) → its feed.
|
|
const srcParam = typeof location !== 'undefined' && new URLSearchParams(location.search).get('source');
|
|
if (srcParam && /^\d+$/.test(srcParam)) {
|
|
await select('source:' + srcParam);
|
|
} else {
|
|
await select('today');
|
|
}
|
|
} catch (e) {
|
|
error = 'Could not reach Upbeat Bytes.';
|
|
}
|
|
loading = false;
|
|
});
|
|
</script>
|
|
|
|
<Header onSaved={() => (showSaved = true)} onaccount={openAccount} user={auth.user} boundariesActive={filtersOn} />
|
|
|
|
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
|
|
{#if showSaved && auth.user}<SavedFlyout onclose={() => (showSaved = false)} />{/if}
|
|
{#if showLanes && lanePool}
|
|
<LanePicker pool={lanePool} selected={pinnedLaneKeys} onsave={saveLanes} onclose={() => (showLanes = false)} />
|
|
{/if}
|
|
|
|
<main class="container">
|
|
{#if navLanes.length}
|
|
<MoodNav lanes={navLanes} {selected} onselect={select} oncustomize={() => (showLanes = true)} />
|
|
{/if}
|
|
|
|
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
|
|
|
{#if loading}
|
|
<p class="muted center pad">Gathering the good news…</p>
|
|
{:else if error}
|
|
<p class="muted center pad">{error}</p>
|
|
{:else}
|
|
{#key selected}
|
|
<header class="view-head rise">
|
|
{#if navStack.length}
|
|
<button class="viewback" onclick={goBack} aria-label="Go back">
|
|
<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
|
|
</button>
|
|
{/if}
|
|
<h1>{viewLabel}</h1>
|
|
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
|
</header>
|
|
|
|
{#if selected === 'today'}
|
|
{#if brief?.items?.length}
|
|
<section class="rise">
|
|
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} onimageerror={heroImageFailed} />
|
|
{#if restArticles.length}
|
|
<div class="grid rest">
|
|
{#each restArticles as a (a.id)}
|
|
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
|
{:else}
|
|
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
|
{/if}
|
|
{:else if feed.length}
|
|
<div class="grid rise">
|
|
{#each feed as a (a.id)}
|
|
<ArticleCard article={a} thumb onaction={applyAction} onreplace={replaceArticle} ontag={(t) => drill('tag:' + t)} onsource={(id, name) => drill('source:' + id, { id, name })} onview={record} />
|
|
{/each}
|
|
</div>
|
|
{#if !feedDone}
|
|
<div class="loadmore">
|
|
<button onclick={loadMore} disabled={loadingMore}>
|
|
{loadingMore ? 'Loading…' : 'Load more'}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<p class="endcap rise">✦ you're all caught up ✦</p>
|
|
{/if}
|
|
{:else}
|
|
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
|
{/if}
|
|
{/key}
|
|
|
|
{#if families.length}
|
|
<section id="explore" class="explore">
|
|
<h2>Explore Upbeat Bytes</h2>
|
|
<div class="families">
|
|
{#each families as f (f.name)}
|
|
{@const tags = f.tags.filter((t) => t.count > 0)}
|
|
{#if tags.length}
|
|
<div class="family">
|
|
<h3>{f.name}</h3>
|
|
<p class="fdesc">{f.description}</p>
|
|
<div class="chips">
|
|
{#each tags as t (t.key)}
|
|
<button class="chip" class:active={selected === 'tag:' + t.key} onclick={() => drill('tag:' + t.key)}>{humanize(t.key)}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{/if}
|
|
</main>
|
|
|
|
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
|
|
|
<style>
|
|
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
|
|
|
.view-head { margin: 18px 0 18px; }
|
|
.viewback {
|
|
display: inline-flex; align-items: center; gap: 5px; margin-bottom: 10px;
|
|
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
|
border-radius: 999px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer;
|
|
transition: border-color 0.14s ease;
|
|
}
|
|
.viewback:hover { border-color: var(--accent); }
|
|
.viewback svg { width: 16px; height: 16px; display: block; }
|
|
.view-head h1 { font-size: clamp(2.1rem, 5.5vw, 2.8rem); line-height: 1.05; text-transform: capitalize; }
|
|
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
|
.view-head::after {
|
|
content: ''; display: block; width: 46px; height: 3px;
|
|
background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
|
}
|
|
|
|
.explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
|
|
.explore h2 {
|
|
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
|
|
color: var(--muted); font-family: var(--label); font-weight: 400; margin: 0 0 22px;
|
|
}
|
|
.families { display: grid; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: 26px 32px; }
|
|
.family h3 { font-size: 1.16rem; margin: 0 0 2px; }
|
|
.family .fdesc { margin: 0 0 11px; color: var(--muted); font-size: 0.86rem; line-height: 1.45; }
|
|
.explore .chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
|
.explore .chip {
|
|
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
|
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer;
|
|
transition: all 0.14s ease; text-transform: capitalize;
|
|
}
|
|
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
|
|
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
|
|
.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;
|
|
}
|
|
.rest { margin-top: 18px; }
|
|
.center { text-align: center; }
|
|
.pad { padding: 48px 0; }
|
|
.endcap {
|
|
text-align: center; color: var(--muted); font-family: var(--serif);
|
|
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
|
}
|
|
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
|
|
.loadmore button {
|
|
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
|
border-radius: 999px; padding: 10px 28px; font-size: 0.92rem; cursor: pointer;
|
|
transition: all 0.14s ease;
|
|
}
|
|
.loadmore button:hover { border-color: var(--accent); background: var(--accent-soft); }
|
|
.loadmore button:disabled { opacity: 0.6; cursor: default; }
|
|
</style>
|