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} + + +