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} @@ -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 @@ + + +
+ More +
+ +
+ {#each data.sections as section (section.category.id)} + {#if section.articles.length > 0} +
+ {section.category.name} +
+ {#each section.articles as article (article.id)} + + {article.title} + {timeAgo(article.publishedAt)} + + {/each} +
+
+ {/if} + {/each} +
+ + diff --git a/frontend/src/routes/more/+page.ts b/frontend/src/routes/more/+page.ts new file mode 100644 index 0000000..bb41710 --- /dev/null +++ b/frontend/src/routes/more/+page.ts @@ -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 }; +};