From 8e1ba82eb4010891e719e8c25ffe76c22a030e2e Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 17:08:55 +0000
Subject: [PATCH] =?UTF-8?q?Add=20category=20nav=20spillover:=20"More=20?=
=?UTF-8?q?=C2=BB"=20tab=20+=20digest=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
backend/src/api/admin.ts | 4 +-
backend/src/storage/db/categories.ts | 22 ++---
backend/src/storage/db/index.ts | 6 +-
backend/src/storage/db/types.ts | 2 +
frontend/src/lib/adminApi.ts | 7 +-
frontend/src/lib/adminTypes.ts | 1 +
.../src/lib/components/admin/MergeTab.svelte | 36 +++++++-
frontend/src/lib/types.ts | 1 +
frontend/src/routes/+layout.svelte | 12 ++-
frontend/src/routes/more/+page.svelte | 91 +++++++++++++++++++
frontend/src/routes/more/+page.ts | 22 +++++
11 files changed, 181 insertions(+), 23 deletions(-)
create mode 100644 frontend/src/routes/more/+page.svelte
create mode 100644 frontend/src/routes/more/+page.ts
diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts
index c52bf4c..c239b2e 100644
--- a/backend/src/api/admin.ts
+++ b/backend/src/api/admin.ts
@@ -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);
});
diff --git a/backend/src/storage/db/categories.ts b/backend/src/storage/db/categories.ts
index da670d8..2397610 100644
--- a/backend/src/storage/db/categories.ts
+++ b/backend/src/storage/db/categories.ts
@@ -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) {
diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts
index 1dd2108..dea8b34 100644
--- a/backend/src/storage/db/index.ts
+++ b/backend/src/storage/db/index.ts
@@ -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');
}
diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts
index d8e73d1..2778201 100644
--- a/backend/src/storage/db/types.ts
+++ b/backend/src/storage/db/types.ts
@@ -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 {
diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts
index 5a2fd2a..82dbeea 100644
--- a/frontend/src/lib/adminApi.ts
+++ b/frontend/src/lib/adminApi.ts
@@ -4,6 +4,7 @@ import type {
AdminSettings,
AdminSource,
AdminTrackedEvent,
+ CategoryPriority,
ModelCatalog,
AiStatus,
TelegramStatus,
@@ -60,10 +61,10 @@ export const updateSettings = (patch: Partial, fetchFn?: typeof f
request('/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(
'/api/admin/categories',
- { method: 'POST', body: JSON.stringify({ name, isPrivate }) },
+ { method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) },
fetchFn
);
diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts
index 0ee3e7d..3c7ec6b 100644
--- a/frontend/src/lib/adminTypes.ts
+++ b/frontend/src/lib/adminTypes.ts
@@ -13,6 +13,7 @@ export interface CategoryPriority {
priorityRank: number;
isDefault: boolean;
isPrivate: boolean;
+ isSpillover: boolean;
}
export interface AdminSettings {
diff --git a/frontend/src/lib/components/admin/MergeTab.svelte b/frontend/src/lib/components/admin/MergeTab.svelte
index 5298704..be0c028 100644
--- a/frontend/src/lib/components/admin/MergeTab.svelte
+++ b/frontend/src/lib/components/admin/MergeTab.svelte
@@ -10,8 +10,15 @@
let saveTimer: ReturnType;
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.
+ {#if primaryCategoryCount > 10}
+
+ {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).
+
+ {/if}
{#each local.categoryPriority as cat, i (cat.id)}
@@ -165,6 +186,10 @@
togglePrivate(cat.id)} />
Private
+
{/if}
Private
+
@@ -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;
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 0a3e773..09e5bcf 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -89,4 +89,5 @@ export interface Category {
priorityRank: number;
isDefault: boolean;
isPrivate: boolean;
+ isSpillover: boolean;
}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 36dadec..b40fe5f 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -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 {
diff --git a/frontend/src/routes/more/+page.svelte b/frontend/src/routes/more/+page.svelte
new file mode 100644
index 0000000..a902c1c
--- /dev/null
+++ b/frontend/src/routes/more/+page.svelte
@@ -0,0 +1,91 @@
+
+
+