Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,34 +161,22 @@ export default function DashboardPage() {
if (!fallback.ok) throw new Error("taxonomy unavailable")
return fallback.json()
})
.then(async (d) => {
let totalSections = Array.isArray(d)
// site_taxonomy is the only place that knows how many sections there are.
// This used to fall back to counting the homepage blocks against a list of
// six keys written here, which answered "6" however many sections the desk
// had actually configured -- a wrong number that looked like a real one.
// The tile keeps its placeholder instead when taxonomy is unavailable.
.then((d) => {
const totalSections = Array.isArray(d)
? d.filter((item) => item?.type === "section").length || d.length
: null
if (totalSections == null || totalSections === 0) {
const homepageRes = await apiFetch("/v1/homepage")
if (homepageRes.ok) {
const homepage = await homepageRes.json()
const sectionKeys = ["news", "opinion", "sports", "entertainment", "candp", "columns"]
totalSections = sectionKeys.filter((key) => Array.isArray(homepage?.[key])).length
}
}
setStats((s) => ({
...s,
totalSections,
}))
})
.catch(async () => {
try {
const homepageRes = await apiFetch("/v1/homepage")
if (!homepageRes.ok) return
const homepage = await homepageRes.json()
const sectionKeys = ["news", "opinion", "sports", "entertainment", "candp", "columns"]
const totalSections = sectionKeys.filter((key) => Array.isArray(homepage?.[key])).length
setStats((s) => ({ ...s, totalSections }))
} catch {
// Keep placeholder when both sources are unavailable.
}
.catch(() => {
// Keep the placeholder rather than guessing.
})

apiFetch("/v1/articles?limit=1")
Expand Down
56 changes: 54 additions & 2 deletions frontend/src/pages/articleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type ArticleItem = {
slug?: string
featuredImage?: string
breakingNews: boolean
isFeatured: boolean
}

type ApiArticle = {
Expand All @@ -28,6 +29,7 @@ type ApiArticle = {
creation_date?: string
featured_image?: string
breaking_news?: boolean
is_featured?: boolean
authors?: Array<{
name?: string
}>
Expand Down Expand Up @@ -97,17 +99,27 @@ type ArticleViewProps = {
// means send no status filter at all.
type PublishedFilter = "all" | "published" | "draft" | "scheduled"

// A flag filter is three-state: "all" sends no parameter at all, the other two
// send breaking/featured explicitly. "Not breaking" has to be a real choice
// rather than the absence of one -- an editor clearing a false alarm wants the
// stories that are NOT flagged.
type FlagFilter = "all" | "on" | "off"

type ArticleViewUIState = {
searchQuery?: string
activeTab?: "all" | "trash"
authorQuery?: string
sectionFilterSlug?: string
subsectionFilterSlug?: string
publishedFilter?: PublishedFilter
breakingFilter?: FlagFilter
featuredFilter?: FlagFilter
dateSortDirection?: "asc" | "desc"
pageSize?: number
}

const flagFilterParam = (filter: FlagFilter) => (filter === "on" ? "true" : "false")

type ArticleResultsCacheEntry = {
items: ArticleItem[]
totalArticleCount: number
Expand Down Expand Up @@ -154,6 +166,8 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
const [subsections, setSubsections] = useState<ApiTaxonomyItem[]>([])
const [subsectionFilterSlug, setSubsectionFilterSlug] = useState(() => loadUIState().subsectionFilterSlug ?? "")
const [publishedFilter, setPublishedFilter] = useState<PublishedFilter>(() => loadUIState().publishedFilter ?? "all")
const [breakingFilter, setBreakingFilter] = useState<FlagFilter>(() => loadUIState().breakingFilter ?? "all")
const [featuredFilter, setFeaturedFilter] = useState<FlagFilter>(() => loadUIState().featuredFilter ?? "all")
const [dateSortDirection, setDateSortDirection] = useState<"asc" | "desc">(() => loadUIState().dateSortDirection ?? "desc")
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
Expand All @@ -169,13 +183,17 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
sectionFilterSlug,
subsectionFilterSlug,
publishedFilter,
breakingFilter,
featuredFilter,
dateSortDirection,
pageSize,
} satisfies ArticleViewUIState)
}, [
activeTab,
authorQuery,
breakingFilter,
dateSortDirection,
featuredFilter,
pageSize,
publishedFilter,
searchQuery,
Expand Down Expand Up @@ -390,6 +408,12 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
if (subsectionFilterSlug) {
params.set("subsection_slug", subsectionFilterSlug)
}
if (breakingFilter !== "all") {
params.set("breaking", flagFilterParam(breakingFilter))
}
if (featuredFilter !== "all") {
params.set("featured", flagFilterParam(featuredFilter))
}
if (fixedType) {
params.set("type", fixedType)
}
Expand Down Expand Up @@ -432,6 +456,7 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
slug: item.slug,
featuredImage: item.featured_image,
breakingNews: Boolean(item.breaking_news),
isFeatured: Boolean(item.is_featured),
}))

if (!cancelled) {
Expand Down Expand Up @@ -469,7 +494,7 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
return () => {
cancelled = true
}
}, [activeTab, apiFetch, authorQuery, dateSortDirection, excludeType, fixedType, page, pageSize, publishedFilter, resultsCacheKey, searchQuery, sectionFilterSlug, selectedAuthorSlug, subsectionFilterSlug])
}, [activeTab, apiFetch, authorQuery, breakingFilter, dateSortDirection, excludeType, featuredFilter, fixedType, page, pageSize, publishedFilter, resultsCacheKey, searchQuery, sectionFilterSlug, selectedAuthorSlug, subsectionFilterSlug])

const onChangeTab = (tab: "all" | "trash") => {
setActiveTab(tab)
Expand All @@ -483,7 +508,7 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article

useEffect(() => {
setPage(0)
}, [authorQuery, publishedFilter, dateSortDirection, searchQuery, sectionFilterSlug, subsectionFilterSlug, pageSize])
}, [authorQuery, publishedFilter, breakingFilter, featuredFilter, dateSortDirection, searchQuery, sectionFilterSlug, subsectionFilterSlug, pageSize])

const effectiveTotalCount = Math.max(totalArticleCount, (page * pageSize) + articles.length)
const totalPages = Math.max(1, Math.ceil(effectiveTotalCount / pageSize))
Expand Down Expand Up @@ -751,6 +776,28 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
</div>
</div>
)}

{/* Breaking and Featured are the two flags an editor sets from the
article form, and the two they need to audit: which stories are
still flagged as breaking, and what is currently pinned. Both are
three-state, so "not flagged" is reachable rather than implied. */}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Breaking</span>
<div className="flex gap-1.5">
<button aria-pressed={breakingFilter === "all"} className={filterTagClass(breakingFilter === "all")} onClick={() => setBreakingFilter("all")} type="button">All</button>
<button aria-pressed={breakingFilter === "on"} className={filterTagClass(breakingFilter === "on")} onClick={() => setBreakingFilter("on")} type="button">Breaking</button>
<button aria-pressed={breakingFilter === "off"} className={filterTagClass(breakingFilter === "off")} onClick={() => setBreakingFilter("off")} type="button">Not breaking</button>
</div>
</div>

<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Featured</span>
<div className="flex gap-1.5">
<button aria-pressed={featuredFilter === "all"} className={filterTagClass(featuredFilter === "all")} onClick={() => setFeaturedFilter("all")} type="button">All</button>
<button aria-pressed={featuredFilter === "on"} className={filterTagClass(featuredFilter === "on")} onClick={() => setFeaturedFilter("on")} type="button">Featured</button>
<button aria-pressed={featuredFilter === "off"} className={filterTagClass(featuredFilter === "off")} onClick={() => setFeaturedFilter("off")} type="button">Not featured</button>
</div>
</div>
</div>

{/* Tabs */}
Expand Down Expand Up @@ -842,6 +889,11 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article
Breaking
</span>
) : null}
{item.isFeatured ? (
<span className="inline-flex shrink-0 items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold uppercase text-amber-700 dark:bg-amber-950/40 dark:text-amber-300">
Featured
</span>
) : null}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{item.authors || "-"}</td>
Expand Down
30 changes: 7 additions & 23 deletions frontend/src/pages/sectionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,6 @@ type EditorState =
| { mode: "create"; label: string }
| { mode: "edit"; item: TaxonomyItem; label: string }

const SECTION_ORDER = [
"news",
"sports",
"opinion",
"columns",
"entertainment",
"comics-puzzles",
"special-editions",
]

const emptyForm: FormState = {
type: "section",
canonicalTitle: "",
Expand Down Expand Up @@ -96,9 +86,7 @@ async function readErrorMessage(response: Response) {
}

function typeLabel(item: TaxonomyItem) {
if (item.type === "section") return "Section"
if (item.parent_slug === "columns") return "Column"
return "Subsection"
return item.type === "section" ? "Section" : "Subsection"
}

export default function SectionsView() {
Expand Down Expand Up @@ -144,16 +132,12 @@ export default function SectionsView() {
const parentSections = useMemo(() => {
return items
.filter((item) => item.type === "section")
.sort((left, right) => {
const leftIndex = SECTION_ORDER.indexOf(left.slug)
const rightIndex = SECTION_ORDER.indexOf(right.slug)
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1
if (rightIndex === -1) return -1
return leftIndex - rightIndex
}
return left.canonical_title.localeCompare(right.canonical_title)
})
// By id, which is the order the sections were created in and so the
// running order the desk gave them. The articles screen orders its
// section filter the same way, so the two screens agree, and a section
// added today lands at the end of both instead of being invisible to a
// list of slugs frozen in this file.
.sort((left, right) => left.id - right.id)
}, [items])

const childrenByParent = useMemo(() => {
Expand Down
12 changes: 12 additions & 0 deletions server/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ const docTemplate = `{
"name": "slug",
"in": "query"
},
{
"type": "boolean",
"description": "When true, return only articles flagged as breaking news; when false, only those not flagged.",
"name": "breaking",
"in": "query"
},
{
"type": "boolean",
"description": "When true, return only featured (pinned) articles; when false, only those not featured.",
"name": "featured",
"in": "query"
},
{
"enum": [
"title",
Expand Down
12 changes: 12 additions & 0 deletions server/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@
"name": "slug",
"in": "query"
},
{
"type": "boolean",
"description": "When true, return only articles flagged as breaking news; when false, only those not flagged.",
"name": "breaking",
"in": "query"
},
{
"type": "boolean",
"description": "When true, return only featured (pinned) articles; when false, only those not featured.",
"name": "featured",
"in": "query"
},
{
"enum": [
"title",
Expand Down
10 changes: 10 additions & 0 deletions server/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,16 @@ paths:
in: query
name: slug
type: string
- description: When true, return only articles flagged as breaking news; when
false, only those not flagged.
in: query
name: breaking
type: boolean
- description: When true, return only featured (pinned) articles; when false,
only those not featured.
in: query
name: featured
type: boolean
- description: Sort field
enum:
- title
Expand Down
Loading
Loading