Files
homefeed/frontend/src/routes/+layout.svelte
T
Claude 7546c0388e Add private categories, unlockable via a password-gated cookie login
Categories can now be marked "Private" in the admin panel's Category
priority list. Private categories (and every article tagged with
one, even if it's also tagged with a public category) are hidden
from /api/categories, /api/feed, and /api/article/:id for anyone
without a valid login — a plain visitor's browser, not the admin API
key, since that's a header-based credential for the admin SPA only.

Login is a single shared password set via PRIVATE_ACCESS_PASSWORD in
the backend's .env (unset by default, which disables the feature
entirely). On success the backend sets a stateless httpOnly cookie —
its value is a deterministic hash of the password, checked with a
timing-safe comparison on every request, so there's no session table
to maintain. The cookie is requested at the ~400-day cap browsers
enforce on persistent cookies, the closest a cookie can get to
"retained indefinitely."

On the frontend, an always-visible lock icon in the masthead (shown
whenever the feature is configured, independent of the admin panel's
own enabled/disabled toggle) opens a password prompt and reflects
locked/unlocked state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-23 02:46:08 +00:00

182 lines
5.3 KiB
Svelte

<script lang="ts">
import '../lib/styles/app.css';
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import PrivateAccessModal from '$lib/components/PrivateAccessModal.svelte';
import { logoutPrivateAccess } from '$lib/privateAccess';
import { slugify } from '$lib/format';
import type { LayoutData } from './$types';
let { children, data }: { children: any; data: LayoutData } = $props();
let showLoginModal = $state(false);
async function handleLockClick() {
if (data.privateAccess.authenticated) {
await logoutPrivateAccess();
await invalidateAll();
} else {
showLoginModal = true;
}
}
async function handleLoginSuccess() {
showLoginModal = false;
await invalidateAll();
}
// "Top stories" is a real Category row (it drives synthesis queue priority) but
// isn't itself a filterable category — it always means "everything, chronological",
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
// page. See MergeTab's category priority list for where these are managed.
const navItems = $derived(
data.categories.map((cat) => ({
label: cat.name,
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
}))
);
function isActive(href: string): boolean {
if (href === '/') return $page.url.pathname === '/';
return $page.url.pathname.startsWith(href);
}
</script>
<header class="masthead">
<div class="page masthead-inner">
<div class="brand">
<span class="brand-name">Homefeed</span>
<span class="brand-tag">self-hosted</span>
</div>
<nav class="tabs">
{#each navItems as item}
<a class="tab" class:active={isActive(item.href)} href={item.href}>{item.label}</a>
{/each}
</nav>
<div class="controls">
<ThemeToggle />
{#if data.privateAccess?.configured}
<button
class="lock-btn"
onclick={handleLockClick}
aria-label={data.privateAccess.authenticated ? 'Log out of private categories' : 'Log in to private categories'}
title={data.privateAccess.authenticated ? 'Log out of private categories' : 'Log in to private categories'}
>
{#if data.privateAccess.authenticated}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 7.75-1.5" />
</svg>
{:else}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
{/if}
</button>
{/if}
{#if data.adminPanelEnabled}
<a class="cog" href="/admin/settings" aria-label="Admin settings" title="Admin settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</a>
{/if}
</div>
</div>
</header>
{#if showLoginModal}
<PrivateAccessModal onClose={() => (showLoginModal = false)} onSuccess={handleLoginSuccess} />
{/if}
<main class="page">
{@render children()}
</main>
<style>
.masthead {
border-bottom: 2px solid var(--text-primary);
}
.masthead-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 20px;
padding-bottom: 12px;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand-name {
font-family: var(--font-voice);
font-size: 28px;
font-weight: 500;
letter-spacing: -0.5px;
}
.brand-tag {
font-size: 11px;
color: var(--text-muted);
border: 0.5px solid var(--border);
padding: 2px 6px;
border-radius: var(--radius);
}
.tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tab {
font-size: 13px;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: var(--radius);
}
.tab:hover {
text-decoration: none;
background: var(--surface-1);
}
.tab.active {
background: var(--pill-bg);
color: var(--pill-text);
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.lock-btn {
display: flex;
align-items: center;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius);
background: transparent;
border: none;
}
.lock-btn:hover {
background: var(--surface-1);
color: var(--text-primary);
}
.cog {
display: flex;
align-items: center;
color: var(--text-secondary);
padding: 4px;
border-radius: var(--radius);
}
.cog:hover {
text-decoration: none;
background: var(--surface-1);
color: var(--text-primary);
}
</style>