From 4fae58d882d9d7d348983a2264fafe8c68373bad Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 13 Aug 2026 00:16:00 -0400 Subject: [PATCH] Give every WordPress sub-category a home without a nav link The taxonomy had two ways to hold a category and neither fit the ones the import left behind. A subsection row gave a category a page and a link in its section's strip; an alias gave it neither, only a way to reach the section's own page. So a category either joined the navigation or stayed anonymous, and 48 of them stayed anonymous -- including 13 that matched nothing at all, leaving wrestling (103 published articles), Triangle Talks (73) and Theater (28) on no section page anywhere. Splitting visibility off from existence covers the case that was missing. site_taxonomy gains is_visible, defaulting to 1 so today's 44 subsections keep their links exactly as they are, and the 48 seeded rows arrive hidden: a slug, a page and a place in the tree, but no entry in the strip. Only subsectionsForSection filters on it -- matching, counting and the item's own listing are untouched, so a hidden subsection still feeds its section. The seed runs once, flagged in cms_settings, because these rows are editable and a deploy must not undo an editor's deletion. It skips a slug already taken and defers entirely against an empty taxonomy, where the sections have not been imported yet rather than there being nothing to do. Editors get the rest of the row: visibility from a toggle in the table or the editor, and section <-> subsection conversion, guarded against demoting a section that still has children and against a (kind, slug) collision. The slug guard stays as it was, since renaming a slug that articles use breaks live URLs. Co-Authored-By: Claude Opus 5 --- frontend/src/pages/sectionsView.tsx | 155 +++++++++-- server/docs/docs.go | 16 ++ server/docs/swagger.json | 16 ++ server/docs/swagger.yaml | 19 ++ .../database/schema/site_taxonomy.sql | 6 + server/internal/database/taxonomy.go | 247 +++++++++++++++++- .../database/taxonomy_integration_test.go | 96 +++++++ server/internal/handlers/handlers.go | 11 +- server/internal/handlers/taxonomy.go | 71 ++++- .../handlers/taxonomy_integration_test.go | 167 ++++++++++++ server/internal/models/types.go | 12 + 11 files changed, 781 insertions(+), 35 deletions(-) diff --git a/frontend/src/pages/sectionsView.tsx b/frontend/src/pages/sectionsView.tsx index ad6d604..ccb9691 100644 --- a/frontend/src/pages/sectionsView.tsx +++ b/frontend/src/pages/sectionsView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react" -import { AlertTriangle, Columns3, Pencil, Plus, RefreshCw, Search, Trash2, X } from "lucide-react" +import { AlertTriangle, Columns3, Eye, EyeOff, Pencil, Plus, RefreshCw, Search, Trash2, X } from "lucide-react" import { useApiFetch } from "../hooks/useApiFetch" type TaxonomyKind = "section" | "subsection" @@ -12,6 +12,7 @@ type TaxonomyItem = { parent_slug?: string | null article_count: number category_aliases?: string[] | null + is_visible?: boolean } type SectionRow = { @@ -31,6 +32,7 @@ type FormState = { // category name, so this is how a section whose slug differs from its // category ("entertainment" vs "Arts & Entertainment") finds its articles. categoryAliases: string + isVisible: boolean } type EditorState = @@ -53,8 +55,14 @@ const emptyForm: FormState = { slug: "", parentSlug: "", categoryAliases: "", + isVisible: true, } +// What hiding actually does, said in full, because two thirds of it is what it +// does NOT do: a hidden item keeps its page and keeps feeding its section. +const visibilityHint = + "Hidden items keep their own page and their articles still appear on the parent section, they just lose their link in the section's subsection list." + // Article matching is exact, so a slug that is not the category name resolves // to nothing and the section page renders empty -- which reads as "no articles // yet" rather than as a misconfiguration. Say what it usually means. @@ -94,6 +102,8 @@ export default function SectionsView() { const [deleteTarget, setDeleteTarget] = useState(null) const [form, setForm] = useState(emptyForm) const [slugTouched, setSlugTouched] = useState(false) + const [togglingId, setTogglingId] = useState(null) + const [showHidden, setShowHidden] = useState(true) const loadSections = useCallback(async () => { setIsLoading(true) @@ -190,14 +200,24 @@ export default function SectionsView() { const filtered = useMemo(() => { const query = search.toLowerCase().trim() - if (!query) return rows - return rows.filter(({ item, parentTitle }) => + // A hidden parent still has to show when its children are on screen, or the + // tree loses its root and the indented rows dangle. + const visibleRows = showHidden + ? rows + : rows.filter(({ item, childCount }) => item.is_visible !== false || childCount > 0) + if (!query) return visibleRows + return visibleRows.filter(({ item, parentTitle }) => item.canonical_title.toLowerCase().includes(query) || item.slug.toLowerCase().includes(query) || typeLabel(item).toLowerCase().includes(query) || (parentTitle ?? "").toLowerCase().includes(query), ) - }, [rows, search]) + }, [rows, search, showHidden]) + + const hiddenCount = useMemo( + () => items.filter((item) => item.is_visible === false).length, + [items], + ) const openCreate = (type: TaxonomyKind, parentSlug = "") => { const parentExists = parentSlug && parentSections.some((section) => section.slug === parentSlug) @@ -218,6 +238,7 @@ export default function SectionsView() { slug: item.slug, parentSlug: item.parent_slug ?? "", categoryAliases: (item.category_aliases ?? []).join("\n"), + isVisible: item.is_visible !== false, }) setSlugTouched(true) setError(null) @@ -281,6 +302,7 @@ export default function SectionsView() { canonical_title: canonicalTitle, parent_slug: form.type === "subsection" ? parentSlug : null, category_aliases: categoryAliases, + is_visible: form.isVisible, } const response = editor.mode === "create" @@ -289,15 +311,13 @@ export default function SectionsView() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) + // The PUT is addressed by the item's CURRENT type and slug, while the + // body carries what it should become -- that is how a section is + // converted into a subsection. : await apiFetch(`/v1/taxonomy/${encodeURIComponent(editor.item.type)}/${encodeURIComponent(editor.item.slug)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - slug, - canonical_title: canonicalTitle, - parent_slug: form.type === "subsection" ? parentSlug : null, - category_aliases: categoryAliases, - }), + body: JSON.stringify(body), }) if (!response.ok) { @@ -314,6 +334,42 @@ export default function SectionsView() { } } + // Visibility is the one field worth changing without opening the editor: it + // is how the desk curates the subsection strip, which is a lot of small + // yes/no decisions across 90-odd rows. + // + // category_aliases is deliberately absent from the body -- omitting it leaves + // the stored value alone, and a toggle must not be able to wipe the matching + // rules that keep a section's page full. + const toggleVisibility = async (item: TaxonomyItem) => { + const nextVisible = item.is_visible === false + setTogglingId(item.id) + setError(null) + try { + const response = await apiFetch(`/v1/taxonomy/${encodeURIComponent(item.type)}/${encodeURIComponent(item.slug)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: item.type, + slug: item.slug, + canonical_title: item.canonical_title, + parent_slug: item.parent_slug ?? null, + is_visible: nextVisible, + }), + }) + if (!response.ok) { + throw new Error(await readErrorMessage(response)) + } + setItems((current) => current.map((row) => ( + row.id === item.id ? { ...row, is_visible: nextVisible } : row + ))) + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to change visibility") + } finally { + setTogglingId(null) + } + } + const deleteTaxonomy = async () => { if (!deleteTarget) return setIsSaving(true) @@ -341,10 +397,21 @@ export default function SectionsView() {

Sections

- {isLoading ? "Loading..." : `${parentSections.length} sections, ${items.filter((item) => item.type === "subsection").length} subsections`} + {isLoading + ? "Loading..." + : `${parentSections.length} sections, ${items.filter((item) => item.type === "subsection").length} subsections${hiddenCount > 0 ? `, ${hiddenCount} hidden` : ""}`}

+ +
- {editor.mode === "create" ? ( - - ) : null} + + +