Skip to content
Open
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
19 changes: 17 additions & 2 deletions app/(frontend)/live/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { getPayload, type CollectionSlug } from 'payload';
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical';
import config from '@/payload.config';
import Footer from '@/components/Footer';
import ArticleScrollBar from '@/components/ArticleScrollBar';
import { ArticleRecommendations } from '@/components/Article/ArticleRecommendations';
import type { Article } from '@/payload-types';
import { ArticleDivider } from '@/components/Article/ArticleDivider';
import { SerializeLexical, type LexicalNode } from '@/components/Article/RichTextParser';
import { extractTextFromLexical, renderLexicalHeadline } from '@/utils/formatArticle';
Expand Down Expand Up @@ -45,6 +48,7 @@ type LiveArticle = {
plainTitle: string;
slug: string;
section: string;
siteSection: Article['section'];
hero: { url: string; alt?: string; width?: number; height?: number; caption?: string };
summary?: LiveArticleSummaryItem[];
updates: LiveArticleUpdate[];
Expand Down Expand Up @@ -165,8 +169,16 @@ export default async function LiveArticlePage({ params }: Args) {
const latest = latestTimestamp(article);
const hasSummary = Array.isArray(article.summary) && article.summary.length > 0;

const siteSection = article.siteSection ?? 'news';

return (
<main className="min-h-screen overflow-x-hidden bg-bg-main pt-[64px] transition-colors duration-300">
<>
<ArticleScrollBar
title={article.plainTitle}
richTitle={renderLexicalHeadline(article.title)}
section={siteSection}
/>
<main className="min-h-screen overflow-x-hidden bg-bg-main pt-[64px] transition-colors duration-300">
<article className="container mx-auto px-4 md:px-6 mt-8 md:mt-12">
<div className="flex flex-col gap-6 mb-8" style={{ paddingTop: '40px' }}>
{/* Meta row: LIVE badge + relative updated time */}
Expand Down Expand Up @@ -260,6 +272,8 @@ export default async function LiveArticlePage({ params }: Args) {
</section>
</article>

<ArticleRecommendations currentArticle={{ section: siteSection }} />

<Footer />

{/*
Expand All @@ -270,6 +284,7 @@ export default async function LiveArticlePage({ params }: Args) {
<style>{`
.live-summary-body p { display: inline; margin: 0; font-size: inherit; line-height: inherit; }
`}</style>
</main>
</main>
</>
);
}
114 changes: 81 additions & 33 deletions app/(frontend)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,43 @@ export default async function Home() {
updates?: Array<{ timestamp?: string | null }> | null;
hero?: number | string | LiveMedia | null;
};
const toLiveStripEntry = (entry: {
id: string | number;
slug: string;
section: string;
publishedDate?: string | null;
updatedAt?: string | null;
updates?: Array<{ timestamp?: string | null }> | null;
hero?: number | string | LiveMedia | null;
}): LiveArticleStripEntry => {
const timestamps = (entry.updates ?? [])
.map((u) => (u?.timestamp ? new Date(u.timestamp).getTime() : NaN))
.filter((t) => !Number.isNaN(t));
const lastUpdateMs = timestamps.length > 0 ? Math.max(...timestamps) : NaN;
const fallbackMs = entry.publishedDate
? new Date(entry.publishedDate).getTime()
: entry.updatedAt
? new Date(entry.updatedAt).getTime()
: NaN;
const latestMs = Number.isNaN(lastUpdateMs) ? fallbackMs : lastUpdateMs;
const lastUpdatedLabel = Number.isNaN(latestMs)
? undefined
: formatRelativeTime(new Date(latestMs));
const hero =
entry.hero && typeof entry.hero === "object"
? (entry.hero as LiveMedia)
: null;
const imageUrl =
hero?.sizes?.card?.url || hero?.url || undefined;
return {
id: String(entry.id),
slug: entry.slug,
section: entry.section,
lastUpdatedLabel,
imageUrl: imageUrl ?? undefined,
};
};

const rawLiveArticles = (layout?.liveArticles ?? []) as LiveArticleRelationEntry[];
const liveStripEntries: LiveArticleStripEntry[] = rawLiveArticles
.filter(
Expand All @@ -212,40 +249,51 @@ export default async function Home() {
typeof entry.slug === "string" &&
typeof entry.section === "string",
)
.map((entry) => {
const timestamps = (entry.updates ?? [])
.map((u) => (u?.timestamp ? new Date(u.timestamp).getTime() : NaN))
.filter((t) => !Number.isNaN(t));
const lastUpdateMs = timestamps.length > 0 ? Math.max(...timestamps) : NaN;
const fallbackMs = entry.publishedDate
? new Date(entry.publishedDate).getTime()
: entry.updatedAt
? new Date(entry.updatedAt).getTime()
: NaN;
const latestMs = Number.isNaN(lastUpdateMs) ? fallbackMs : lastUpdateMs;
const lastUpdatedLabel = Number.isNaN(latestMs)
? undefined
: formatRelativeTime(new Date(latestMs));
const hero =
entry.hero && typeof entry.hero === "object"
? (entry.hero as LiveMedia)
: null;
const imageUrl =
hero?.sizes?.card?.url || hero?.url || undefined;
return {
id: String(entry.id),
slug: entry.slug,
section: entry.section,
lastUpdatedLabel,
imageUrl: imageUrl ?? undefined,
};
});
.map(toLiveStripEntry);

// The layout's `liveArticles` list is editor-curated and is frequently left
// empty, which used to leave live blogs with no entry point anywhere on the
// site. When nothing is curated, fall back to the most recently updated
// published live articles so the homepage strip is always a way in.
let liveStripEntriesResolved = liveStripEntries;
if (liveStripEntriesResolved.length === 0) {
try {
const recentLive = await payload.find({
collection: 'live-articles',
limit: 4,
depth: 1,
sort: '-updatedAt',
where: { _status: { equals: 'published' } },
});
liveStripEntriesResolved = (recentLive.docs as unknown as LiveArticleRelationEntry[])
.filter(
(
entry,
): entry is {
id: string | number;
slug: string;
section: string;
publishedDate?: string | null;
updatedAt?: string | null;
updates?: Array<{ timestamp?: string | null }> | null;
hero?: number | string | LiveMedia | null;
} =>
typeof entry === "object" &&
entry !== null &&
typeof entry.slug === "string" &&
typeof entry.section === "string",
)
.map(toLiveStripEntry);
} catch (err) {
console.error("[home] live-articles fallback query failed:", err);
}
}

if (!layout) {
return (
<main className="min-h-screen flex flex-col bg-bg-main transition-colors duration-300">
<ArticleScrollBar />
<Header liveEntries={liveStripEntries} />
<Header liveEntries={liveStripEntriesResolved} />
<div className="flex flex-col items-center justify-center flex-1 px-4 text-center">
<h1 className="font-display text-[28px] md:text-[36px] font-bold text-text-main mb-3">We&apos;ll be right back</h1>
<p className="font-copy text-[15px] md:text-[17px] text-text-muted max-w-md">The Polytechnic is currently under maintenance. Please check back shortly.</p>
Expand Down Expand Up @@ -552,7 +600,7 @@ export default async function Home() {
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd).replace(/</g, '\\u003c') }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd).replace(/</g, '\\u003c') }} />
<ArticleScrollBar />
<Header liveEntries={liveStripEntries} />
<Header liveEntries={liveStripEntriesResolved} />
<GeminiHomepage
lead={lead}
leftStack={leftStack}
Expand Down Expand Up @@ -584,7 +632,7 @@ export default async function Home() {
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd).replace(/</g, '\\u003c') }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd).replace(/</g, '\\u003c') }} />
<ArticleScrollBar />
<Header liveEntries={liveStripEntries} />
<Header liveEntries={liveStripEntriesResolved} />
<div className="w-full bg-bg-main text-text-main transition-colors duration-300">
<div className="mx-auto max-w-[1280px] px-4 pb-14 md:px-6 xl:px-[30px]">
<div data-frontpage-top className="pt-5 md:pt-7">
Expand All @@ -610,7 +658,7 @@ export default async function Home() {
return (
<main className="min-h-screen flex flex-col bg-bg-main transition-colors duration-300">
<ArticleScrollBar />
<Header liveEntries={liveStripEntries} />
<Header liveEntries={liveStripEntriesResolved} />
<div className="flex flex-col items-center justify-center flex-1 px-4 text-center">
<h1 className="font-display text-[28px] md:text-[36px] font-bold text-text-main mb-3">We&apos;ll be right back</h1>
<p className="font-copy text-[15px] md:text-[17px] text-text-muted max-w-md">The Polytechnic is currently under maintenance. Please check back shortly.</p>
Expand Down Expand Up @@ -692,7 +740,7 @@ export default async function Home() {
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd).replace(/</g, '\\u003c') }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd).replace(/</g, '\\u003c') }} />
<ArticleScrollBar />
<Header liveEntries={liveStripEntries} />
<Header liveEntries={liveStripEntriesResolved} />
<FrontPage
topStories={topStories}
layoutName={layout.skeleton === 'taurus' ? 'taurus' : 'aries'}
Expand Down
21 changes: 21 additions & 0 deletions collections/LiveArticles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,27 @@ const LiveArticles: CollectionConfig = {
description: 'Short topic label shown on the homepage strip (e.g. "Labor Department", "Election Night").',
},
},
{
// The real section taxonomy, mirroring Articles.section. Distinct from
// `section` above, which is a free-text topic label for the homepage
// strip. This drives the short scroll header and the "Continue Reading"
// recommendations on the live article page.
name: 'siteSection',
type: 'select',
required: true,
defaultValue: 'news',
label: 'Site Section',
options: [
{ label: 'News', value: 'news' },
{ label: 'Sports', value: 'sports' },
{ label: 'Features', value: 'features' },
{ label: 'Opinion', value: 'opinion' },
],
admin: {
position: 'sidebar',
description: 'Which section of the site this live blog belongs to. Drives the section header and related-stories block.',
},
},
{
name: 'hero',
type: 'upload',
Expand Down
24 changes: 20 additions & 4 deletions components/Article/ArticleRecommendations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ import { opinionTypeLabels } from '@/components/Opinion/opinionTypeLabels';
import { Article, Media, User } from '@/payload-types';
import { getArticleUrl } from '@/utils/getArticleUrl';

/**
* The minimum shape this block needs. `Article` satisfies it structurally, and
* live articles (a separate collection) can pass their own section + id so the
* "Continue Reading" block works on /live pages too.
*/
export type RecommendationContext = {
id?: number;
section: Article['section'];
kicker?: string | null;
opinionType?: Article['opinionType'];
};

type Props = {
currentArticle: Article;
currentArticle: RecommendationContext;
};

const sectionLabels: Record<Article['section'], string> = {
Expand Down Expand Up @@ -61,7 +73,7 @@ type RecommendationArticle = {
opinionType?: string | null;
};

const getOpinionType = (article: RecommendationArticle | Article) =>
const getOpinionType = (article: RecommendationArticle | Article | RecommendationContext) =>
(article as unknown as Record<string, unknown>).opinionType as string | undefined;

const getFeaturedImage = (value: RecommendationArticle['featuredImage'] | Media | number | null | undefined): RecommendationImage | null => {
Expand Down Expand Up @@ -127,7 +139,7 @@ const getHeadlineClasses = (article: RecommendationArticle, variant: 'lead' | 'l
return `${base} text-text-main transition-colors ${sectionStyles}`;
};

const prioritizeRecommendations = (articles: RecommendationArticle[], currentArticle: Article) => {
const prioritizeRecommendations = (articles: RecommendationArticle[], currentArticle: RecommendationContext) => {
if (currentArticle.section !== 'opinion') return articles;

const currentOpinionType = getOpinionType(currentArticle);
Expand Down Expand Up @@ -183,7 +195,11 @@ export async function ArticleRecommendations({ currentArticle }: Props) {
and: [
{ _status: { equals: 'published' } },
{ section: { equals: currentArticle.section } },
{ id: { not_equals: currentArticle.id } },
// Live articles live in a different collection, so there is no
// articles row to exclude when this block renders on a /live page.
...(currentArticle.id === undefined
? []
: [{ id: { not_equals: currentArticle.id } }]),
],
},
sort: '-publishedDate',
Expand Down
5 changes: 5 additions & 0 deletions components/HeaderClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ export default function Header({ compact = false, mobileTight = false, logoSrcs,
</span>
</div>
</div>
{liveEntries && liveEntries.length > 0 && (
<div className="safe-area-mobile-header-x mx-auto max-w-[1280px] border-b border-black dark:border-[#DDDDDD]">
<LiveStrip entries={liveEntries} />
</div>
)}
</header>

<MobileMenuDrawer
Expand Down
48 changes: 48 additions & 0 deletions migrations/20260906_010000_add_live_articles_site_section.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'

/**
* Add `site_section` to `live-articles` (+ version shadow).
*
* `live_articles.section` already existed but is a free-text topic label for
* the homepage strip ("Labor Department", "Election Night") — it is not the
* site's section taxonomy. Live article pages need the real taxonomy so they
* can render the short scroll header and the "Continue Reading / <Section>"
* recommendations block the way standard article pages do.
*
* Existing rows are backfilled to 'news', which matches the only live article
* shipped so far and is the safest default for a required field.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$ BEGIN
CREATE TYPE "public"."enum_live_articles_site_section" AS ENUM('news', 'sports', 'features', 'opinion');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

DO $$ BEGIN
CREATE TYPE "public"."enum__live_articles_v_version_site_section" AS ENUM('news', 'sports', 'features', 'opinion');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

ALTER TABLE "live_articles"
ADD COLUMN IF NOT EXISTS "site_section" "enum_live_articles_site_section" DEFAULT 'news';
ALTER TABLE "_live_articles_v"
ADD COLUMN IF NOT EXISTS "version_site_section" "enum__live_articles_v_version_site_section" DEFAULT 'news';

UPDATE "live_articles" SET "site_section" = 'news' WHERE "site_section" IS NULL;
UPDATE "_live_articles_v" SET "version_site_section" = 'news' WHERE "version_site_section" IS NULL;

CREATE INDEX IF NOT EXISTS "live_articles_site_section_idx"
ON "live_articles" USING btree ("site_section");
`)
}

export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX IF EXISTS "live_articles_site_section_idx";
ALTER TABLE "_live_articles_v" DROP COLUMN IF EXISTS "version_site_section";
ALTER TABLE "live_articles" DROP COLUMN IF EXISTS "site_section";
DROP TYPE IF EXISTS "public"."enum__live_articles_v_version_site_section";
DROP TYPE IF EXISTS "public"."enum_live_articles_site_section";
`)
}
8 changes: 7 additions & 1 deletion migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ import * as migration_20260506_000000_add_articles_legacy_archive from './202605
import * as migration_20260506_010000_add_articles_legacy_id_and_category from './20260506_010000_add_articles_legacy_id_and_category';
import * as migration_20260506_020000_add_articles_plain_content from './20260506_020000_add_articles_plain_content';
import * as migration_20260507_000000_add_articles_previous_slug from './20260507_000000_add_articles_previous_slug';
import * as migration_20260507_010000_add_legacy_shortlinks from './20260507_010000_add_legacy_shortlinks';
import * as migration_20260507_010000_add_legacy_shortlinks from './20260507_010000_add_legacy_shortlinks'
import * as migration_20260906_000000_fix_schema_drift from './20260906_000000_fix_schema_drift'
import * as migration_20260906_010000_add_live_articles_site_section from './20260906_010000_add_live_articles_site_section'

export const migrations = [
{
Expand Down Expand Up @@ -282,4 +283,9 @@ export const migrations = [
down: migration_20260906_000000_fix_schema_drift.down,
name: '20260906_000000_fix_schema_drift',
},
{
up: migration_20260906_010000_add_live_articles_site_section.up,
down: migration_20260906_010000_add_live_articles_site_section.down,
name: '20260906_010000_add_live_articles_site_section',
},
];
5 changes: 5 additions & 0 deletions payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ export interface LiveArticle {
* Short topic label shown on the homepage strip (e.g. "Labor Department", "Election Night").
*/
section: string;
/**
* Which section of the site this live blog belongs to. Drives the section header and related-stories block.
*/
siteSection: 'news' | 'sports' | 'features' | 'opinion';
hero: number | Media;
summary?:
| {
Expand Down Expand Up @@ -1052,6 +1056,7 @@ export interface LiveArticlesSelect<T extends boolean = true> {
plainTitle?: T;
slug?: T;
section?: T;
siteSection?: T;
hero?: T;
summary?:
| T
Expand Down
Loading