Add category nav spillover: "More »" tab + digest page
Categories can now be flagged isSpillover in the admin priority list, collapsing them out of the main nav into a single "More »" tab that links to a new /more page listing each spillover category's newest articles, with the category name linking through to its full page. Keeps the nav from growing unbounded or word-wrapping as categories are added. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -36,9 +36,9 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
|
||||
// --- Categories (add/remove — reordering/privacy is via PATCH /settings above) ---
|
||||
app.post('/api/admin/categories', async (req, reply) => {
|
||||
const { name, isPrivate } = req.body as { name?: string; isPrivate?: boolean };
|
||||
const { name, isPrivate, isSpillover } = req.body as { name?: string; isPrivate?: boolean; isSpillover?: boolean };
|
||||
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
|
||||
const created = categoriesDb.createCategory(name.trim(), !!isPrivate);
|
||||
const created = categoriesDb.createCategory(name.trim(), !!isPrivate, !!isSpillover);
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ function rowToCategory(row: any): Category {
|
||||
name: row.name,
|
||||
priorityRank: row.priority_rank,
|
||||
isDefault: !!row.is_default,
|
||||
isPrivate: !!row.is_private
|
||||
isPrivate: !!row.is_private,
|
||||
isSpillover: !!row.is_spillover
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,21 +24,18 @@ export function listPrivateCategoryNames(): string[] {
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean }[]) {
|
||||
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ? WHERE id = ?');
|
||||
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.id);
|
||||
export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean; isSpillover: boolean }[]) {
|
||||
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ?, is_spillover = ? WHERE id = ?');
|
||||
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.isSpillover ? 1 : 0, c.id);
|
||||
}
|
||||
|
||||
export function createCategory(name: string, isPrivate = false): Category {
|
||||
export function createCategory(name: string, isPrivate = false, isSpillover = false): Category {
|
||||
const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number };
|
||||
db.prepare('INSERT INTO categories (id, name, priority_rank, is_default, is_private) VALUES (?, ?, ?, 0, ?)').run(
|
||||
id,
|
||||
name,
|
||||
maxRank.m + 1,
|
||||
isPrivate ? 1 : 0
|
||||
);
|
||||
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate };
|
||||
db.prepare(
|
||||
'INSERT INTO categories (id, name, priority_rank, is_default, is_private, is_spillover) VALUES (?, ?, ?, 0, ?, ?)'
|
||||
).run(id, name, maxRank.m + 1, isPrivate ? 1 : 0, isSpillover ? 1 : 0);
|
||||
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate, isSpillover };
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string) {
|
||||
|
||||
@@ -144,7 +144,8 @@ export function migrate() {
|
||||
name TEXT NOT NULL,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_private INTEGER NOT NULL DEFAULT 0
|
||||
is_private INTEGER NOT NULL DEFAULT 0,
|
||||
is_spillover INTEGER NOT NULL DEFAULT 0 -- collapsed into the nav's "More »" overflow page instead of its own tab
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
@@ -229,6 +230,9 @@ export function migrate() {
|
||||
if (!hasColumn('categories', 'is_private')) {
|
||||
db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!hasColumn('categories', 'is_spillover')) {
|
||||
db.exec('ALTER TABLE categories ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!hasColumn('content_items', 'telegram_message')) {
|
||||
db.exec('ALTER TABLE content_items ADD COLUMN telegram_message TEXT');
|
||||
}
|
||||
|
||||
@@ -202,6 +202,8 @@ export interface Category {
|
||||
isDefault: boolean;
|
||||
/** Hidden from /api/categories, /api/feed, and article detail for anyone without a valid private-access cookie. */
|
||||
isPrivate: boolean;
|
||||
/** Grouped into the nav's "More »" overflow page instead of getting its own top-level tab — see +layout.svelte and /more. */
|
||||
isSpillover: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AdminSettings,
|
||||
AdminSource,
|
||||
AdminTrackedEvent,
|
||||
CategoryPriority,
|
||||
ModelCatalog,
|
||||
AiStatus,
|
||||
TelegramStatus,
|
||||
@@ -60,10 +61,10 @@ export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof f
|
||||
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
||||
|
||||
// Categories
|
||||
export const createCategory = (name: string, isPrivate = false, fetchFn?: typeof fetch) =>
|
||||
request<{ id: string; name: string; priorityRank: number; isDefault: boolean; isPrivate: boolean }>(
|
||||
export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) =>
|
||||
request<CategoryPriority>(
|
||||
'/api/admin/categories',
|
||||
{ method: 'POST', body: JSON.stringify({ name, isPrivate }) },
|
||||
{ method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) },
|
||||
fetchFn
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CategoryPriority {
|
||||
priorityRank: number;
|
||||
isDefault: boolean;
|
||||
isPrivate: boolean;
|
||||
isSpillover: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
|
||||
@@ -10,8 +10,15 @@
|
||||
let saveTimer: ReturnType<typeof setTimeout>;
|
||||
let newCategoryName = $state('');
|
||||
let newCategoryPrivate = $state(false);
|
||||
let newCategorySpillover = $state(false);
|
||||
let addingCategory = $state(false);
|
||||
|
||||
// Advisory only — the nav starts getting too wide / wrapping past ~10 tabs, so this
|
||||
// nudges the admin toward marking some categories as spillover once they cross that
|
||||
// rough guideline. Never enforced — actual wrapping depends on name lengths and
|
||||
// viewport width, which this simple count can't know.
|
||||
const primaryCategoryCount = $derived(local.categoryPriority.filter((c) => !c.isSpillover).length);
|
||||
|
||||
function scheduleSave() {
|
||||
status = 'saving';
|
||||
clearTimeout(saveTimer);
|
||||
@@ -40,10 +47,11 @@
|
||||
if (!name) return;
|
||||
addingCategory = true;
|
||||
try {
|
||||
const created = await createCategory(name, newCategoryPrivate);
|
||||
const created = await createCategory(name, newCategoryPrivate, newCategorySpillover);
|
||||
local.categoryPriority = [...local.categoryPriority, created];
|
||||
newCategoryName = '';
|
||||
newCategoryPrivate = false;
|
||||
newCategorySpillover = false;
|
||||
} finally {
|
||||
addingCategory = false;
|
||||
}
|
||||
@@ -54,6 +62,11 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function toggleSpillover(id: string) {
|
||||
local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, isSpillover: !c.isSpillover } : c));
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function removeCategory(id: string, isDefault: boolean, name: string) {
|
||||
if (isDefault) {
|
||||
// Sensible-default categories can still be removed — e.g. a fresh install's
|
||||
@@ -153,8 +166,16 @@
|
||||
categories just wait longer when the queue is busy. This list also drives the site's nav —
|
||||
remove anything you're not interested in (Business, Culture, etc.) or add your own. A
|
||||
private category (and everything in it) is hidden from the public site until a visitor
|
||||
logs in with the lock icon in the masthead.
|
||||
logs in with the lock icon in the masthead. A "More" category is collapsed into a single
|
||||
"More »" nav tab instead of getting its own, and shows up on that overflow page with its
|
||||
latest few articles.
|
||||
</p>
|
||||
{#if primaryCategoryCount > 10}
|
||||
<p class="hint warn">
|
||||
{primaryCategoryCount} categories showing directly in the nav — consider marking some as
|
||||
"More" below before it gets too wide (a rough guideline, not a hard limit).
|
||||
</p>
|
||||
{/if}
|
||||
<div class="priority-list">
|
||||
{#each local.categoryPriority as cat, i (cat.id)}
|
||||
<div class="priority-row">
|
||||
@@ -165,6 +186,10 @@
|
||||
<input type="checkbox" checked={cat.isPrivate} onchange={() => togglePrivate(cat.id)} />
|
||||
Private
|
||||
</label>
|
||||
<label class="private-toggle">
|
||||
<input type="checkbox" checked={cat.isSpillover} onchange={() => toggleSpillover(cat.id)} />
|
||||
More
|
||||
</label>
|
||||
{/if}
|
||||
<button class="icon-btn" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Move up">▲</button>
|
||||
<button
|
||||
@@ -192,6 +217,10 @@
|
||||
<input type="checkbox" bind:checked={newCategoryPrivate} />
|
||||
Private
|
||||
</label>
|
||||
<label class="private-toggle">
|
||||
<input type="checkbox" bind:checked={newCategorySpillover} />
|
||||
More
|
||||
</label>
|
||||
<button onclick={addCategory} disabled={addingCategory || !newCategoryName.trim()}>
|
||||
{addingCategory ? 'Adding…' : '+ Add'}
|
||||
</button>
|
||||
@@ -249,6 +278,9 @@
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
.hint.warn {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -89,4 +89,5 @@ export interface Category {
|
||||
priorityRank: number;
|
||||
isDefault: boolean;
|
||||
isPrivate: boolean;
|
||||
isSpillover: boolean;
|
||||
}
|
||||
|
||||
@@ -29,18 +29,24 @@
|
||||
// "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.
|
||||
// page, unless it's flagged "spillover" (see MergeTab.svelte's category priority
|
||||
// list) — those collapse into a single trailing "More »" tab instead, so the nav
|
||||
// doesn't get too wide or wrap once there are more than a handful of categories.
|
||||
//
|
||||
// A tracked event is a displayed category too, just backed by a source+keyword
|
||||
// filter instead of manual per-source category checkboxes, and periodically
|
||||
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
|
||||
// appended after the regular categories.
|
||||
const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
|
||||
const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
|
||||
|
||||
const navItems = $derived([
|
||||
...data.categories.map((cat) => ({
|
||||
...primaryCategories.map((cat) => ({
|
||||
label: cat.name,
|
||||
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
|
||||
})),
|
||||
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` }))
|
||||
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
|
||||
...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
|
||||
]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { timeAgo, slugify } from '$lib/format';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="head">
|
||||
<span class="title">More</span>
|
||||
</div>
|
||||
|
||||
<div class="sections">
|
||||
{#each data.sections as section (section.category.id)}
|
||||
{#if section.articles.length > 0}
|
||||
<section class="cat-section">
|
||||
<a class="cat-name" href={`/category/${slugify(section.category.name)}`}>{section.category.name}</a>
|
||||
<div class="preview-list">
|
||||
{#each section.articles as article (article.id)}
|
||||
<a class="preview-row" href={`/article/${article.id}`}>
|
||||
<span class="preview-title">{article.title}</span>
|
||||
<span class="preview-time">{timeAgo(article.publishedAt)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.head {
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-voice);
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cat-section {
|
||||
border-bottom: 0.5px solid var(--border);
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
.cat-name {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cat-name:hover {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
.preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.preview-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-top: 0.5px solid var(--border);
|
||||
color: inherit;
|
||||
}
|
||||
.preview-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.preview-row:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.preview-row:hover .preview-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.preview-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.preview-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { getFeed } from '$lib/api';
|
||||
|
||||
const PREVIEW_COUNT = 4;
|
||||
|
||||
// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
|
||||
// category (see MergeTab.svelte's "More" toggle) with its few newest articles, the
|
||||
// category name itself linking through to the full /category/:slug page. Mirrors
|
||||
// category/[name]/+page.ts's parent()-based category access rather than a second fetch.
|
||||
export const load: PageLoad = async ({ fetch, parent }) => {
|
||||
const { categories } = await parent();
|
||||
const spillover = categories.filter((c) => c.isSpillover);
|
||||
|
||||
const sections = await Promise.all(
|
||||
spillover.map(async (category) => ({
|
||||
category,
|
||||
articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch)
|
||||
}))
|
||||
);
|
||||
|
||||
return { sections };
|
||||
};
|
||||
Reference in New Issue
Block a user