Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0585749
Added barebones opinion article section and opinion article format w/…
anthonysantiago-eng Feb 25, 2026
26f524d
Merge origin/main into style/opinion-articles, resolve conflicts
anthonysantiago-eng Feb 25, 2026
77f982b
Potential fix for pull request finding 'Unused variable, import, func…
anthonysantiago-eng Feb 25, 2026
2a649b3
Fix grey text in opinion articles, override to black
anthonysantiago-eng Feb 25, 2026
7248f78
Merge branch 'style/opinion-articles' of github.com:thepoly/polymer i…
anthonysantiago-eng Feb 25, 2026
1ed776e
Remove hamburger menu from OpinionHeader, keep logo and search only
anthonysantiago-eng Mar 13, 2026
e539668
Smooth scroll bar transition with hysteresis zone
anthonysantiago-eng Mar 13, 2026
61f9818
Refactor opinion section: dynamic footer, type labels, remove editori…
anthonysantiago-eng Mar 14, 2026
4157d44
Fix invalid enum error in OpinionArticleFooter query
anthonysantiago-eng Mar 15, 2026
bc74972
Merge remote-tracking branch 'origin/main' into style/opinion-articles
anthonysantiago-eng Mar 15, 2026
9c19521
Redesign opinion page layout with section header and tiered article rows
anthonysantiago-eng Mar 15, 2026
f714bdc
Add working search overlay with live article results
anthonysantiago-eng Mar 15, 2026
193fccb
Capitalize section labels in search results
anthonysantiago-eng Mar 15, 2026
08fb0f5
Merge origin/main into style/opinion-articles, resolve conflicts
anthonysantiago-eng Mar 17, 2026
c06c4b9
Resolve merge conflicts with main; fix migrations for safe merge
RonanHevenor Mar 28, 2026
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
74 changes: 40 additions & 34 deletions app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { getPayload } from 'payload';
import config from '@/payload.config';
import { getArticleLayout, ArticleLayouts } from '@/components/Article/Layouts';
import { LexicalNode } from '@/components/Article/RichTextParser';
import OpinionHeader from '@/components/Opinion/OpinionHeader';
import { OpinionArticleHeader } from '@/components/Opinion/OpinionArticleHeader';
import OpinionScrollBar from '@/components/Opinion/OpinionScrollBar';
import { OpinionArticleFooter } from '@/components/Opinion/OpinionArticleFooter';
import { ArticleContent, ArticleFooter } from '@/components/Article';
import ArticleScrollBar from '@/components/ArticleScrollBar';
import ArticleAnalytics from '@/components/analytics/ArticleAnalytics';
import { InlineEditor } from '@/components/Article/InlineEditor';
Expand Down Expand Up @@ -207,7 +212,6 @@ export default async function ArticlePage({ params }: Args) {
notFound();
}

// Determine the layout
const layoutType = getArticleLayout(article);
const LayoutComponent = ArticleLayouts[layoutType];

Expand All @@ -221,19 +225,37 @@ export default async function ArticlePage({ params }: Args) {
let cleanContent = article.content;

if (layoutType === 'photofeature') {
const firstNode = article.content?.root?.children?.[0] as unknown as LexicalNode;
if (article.content && firstNode?.type === 'paragraph' && firstNode.children && firstNode.children.length > 0) {
const firstTextNode = firstNode.children[0];
if (firstTextNode.type === 'text' && firstTextNode.text?.trim() === '#photofeature#') {
cleanContent = {
...article.content,
root: {
...article.content.root,
children: (article.content.root.children as unknown as LexicalNode[]).slice(1)
}
};
}
const firstNode = article.content?.root?.children?.[0] as unknown as LexicalNode;
if (article.content && firstNode?.type === 'paragraph' && firstNode.children && firstNode.children.length > 0) {
const firstTextNode = firstNode.children[0];
if (firstTextNode.type === 'text' && firstTextNode.text?.trim() === '#photofeature#') {
cleanContent = {
...article.content,
root: {
...article.content.root,
children: (article.content.root.children as unknown as LexicalNode[]).slice(1),
},
};
}
}
}

// Opinion articles get their own custom layout
if (section === 'opinion' && layoutType !== 'photofeature') {
return (
<main className="min-h-screen bg-white pb-20 pt-[58px] font-[family-name:var(--font-raleway)]">
<OpinionHeader />
<OpinionScrollBar title={article.title} />
<article className="container mx-auto px-4 md:px-6 mt-8 md:mt-12">
<OpinionArticleHeader article={article} />
<div className="max-w-[600px] mx-auto [--foreground-muted:#000000] [--color-text-muted:#000000]">
<ArticleContent content={article.content} />
<ArticleFooter />
</div>
</article>
<OpinionArticleFooter currentArticleId={article.id} />
</main>
);
}

const staffAuthorsForJsonLd = (article.authors || []).filter((author): author is User => typeof author !== 'number');
Expand All @@ -247,23 +269,9 @@ export default async function ArticlePage({ params }: Args) {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: '/',
},
{
'@type': 'ListItem',
position: 2,
name: sectionTitle,
item: `/${article.section}`,
},
{
'@type': 'ListItem',
position: 3,
name: article.title,
},
{ '@type': 'ListItem', position: 1, name: 'Home', item: '/' },
{ '@type': 'ListItem', position: 2, name: sectionTitle, item: `/${article.section}` },
{ '@type': 'ListItem', position: 3, name: article.title },
],
};

Expand All @@ -272,9 +280,7 @@ export default async function ArticlePage({ params }: Args) {
'@type': 'NewsArticle',
headline: article.title,
...(article.subdeck && { description: article.subdeck }),
...(image?.url && {
image: [image.url],
}),
...(image?.url && { image: [image.url] }),
datePublished: article.publishedDate || article.createdAt,
dateModified: article.updatedAt,
author: [
Expand Down Expand Up @@ -350,7 +356,7 @@ export async function generateStaticParams() {
const date = new Date(dateStr);
const year = date.getFullYear().toString();
const month = String(date.getMonth() + 1).padStart(2, '0');

return {
section: doc.section,
year,
Expand Down
64 changes: 64 additions & 0 deletions app/(frontend)/opinion/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import { getPayload } from 'payload';
import config from '@/payload.config';
import OpinionHeader from '@/components/Opinion/OpinionHeader';
import { OpinionArticleGrid } from '@/components/Opinion/OpinionArticleGrid';
import { OpinionArticle } from '@/components/Opinion/types';
import { Article as PayloadArticle, Media } from '@/payload-types';

export const revalidate = 60;

const formatOpinionArticle = (article: PayloadArticle): OpinionArticle | null => {
if (!article || typeof article === 'number') return null;

const authors = article.authors
?.map((author) => {
if (typeof author === 'number') return null;
return `${author.firstName} ${author.lastName}`;
})
.filter(Boolean)
.join(' AND ') || null;

return {
id: article.id,
slug: article.slug || article.title.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''),
title: article.title,
excerpt: article.subdeck || '',
author: authors,
authorId: null,
authorHeadshot: null,
date: null,
publishedDate: article.publishedDate,
createdAt: article.createdAt,
image: (article.featuredImage as Media)?.url || null,
section: article.section,
opinionType: article.opinionType || null,
};
};

export default async function OpinionPage() {
const payload = await getPayload({ config });

const result = await payload.find({
collection: 'articles',
where: {
section: { equals: 'opinion' },
},
sort: '-publishedDate',
limit: 100,
depth: 2,
});

const articles = result.docs
.map(formatOpinionArticle)
.filter((a): a is OpinionArticle => a !== null);

return (
<main className="min-h-screen bg-white pt-[58px]">
<OpinionHeader />
<div className="max-w-[1280px] mx-auto px-4 md:px-6 py-8">
<OpinionArticleGrid articles={articles} />
</div>
</main>
);
}
28 changes: 28 additions & 0 deletions collections/Articles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CollectionConfig } from 'payload'
import { deriveSlug } from '../utils/deriveSlug'
import { getPostHogClient } from '../lib/posthog-server'

const Articles: CollectionConfig = {
Expand Down Expand Up @@ -63,6 +64,11 @@ const Articles: CollectionConfig = {
],
beforeChange: [
({ data, originalDoc }) => {
// Auto-generate slug from title if not provided
if (!data.slug && data.title) {
data.slug = deriveSlug(data.title)
}

// LOGIC: If transitioning to 'published' via Payload's internal _status, set the publishedDate
const isNowPublished = data._status === 'published'
const wasPublished = originalDoc?._status === 'published'
Expand Down Expand Up @@ -154,6 +160,28 @@ const Articles: CollectionConfig = {
hidden: true,
},
},
{
name: 'opinionType',
type: 'select',
options: [
{ label: 'Opinion', value: 'opinion' },
{ label: 'Column', value: 'column' },
{ label: 'Staff Editorial', value: 'staff-editorial' },
{ label: 'Editorial Notebook', value: 'editorial-notebook' },
{ label: 'Endorsement', value: 'endorsement' },
{ label: 'Top Hat', value: 'top-hat' },
{ label: 'Candidate Profile', value: 'candidate-profile' },
{ label: 'Letter to the Editor', value: 'letter-to-the-editor' },
{ label: "The Poly's Recommendations", value: 'polys-recommendations' },
{ label: "Editor's Notebook", value: 'editors-notebook' },
{ label: 'Derby', value: 'derby' },
{ label: 'Other', value: 'other' },
],
admin: {
condition: (data) => data?.section === 'opinion' || data?.section === 'editorial',
description: 'Categorizes opinion articles. Only visible when section is Opinion.',
},
},
{
name: 'authors',
type: 'relationship',
Expand Down
9 changes: 8 additions & 1 deletion collections/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,16 @@ export const Users: CollectionConfig = {
type: 'upload',
relationTo: 'media',
},
{
name: 'oneLiner',
type: 'text',
admin: {
description: 'A short one-line description (e.g. "is a senior studying computer science")',
},
},
{
name: 'bio',
type: 'richText',
type: 'richText',
},
{
name: 'positions',
Expand Down
76 changes: 76 additions & 0 deletions components/Opinion/OpinionArticleFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { getPayload } from 'payload';
import config from '@/payload.config';
import { deriveSlug } from '@/utils/deriveSlug';
import { Article, Media } from '@/payload-types';
import { opinionTypeLabels } from './opinionTypeLabels';

type Props = {
currentArticleId: number;
};

export const OpinionArticleFooter: React.FC<Props> = async ({ currentArticleId }) => {
const payload = await getPayload({ config });

const result = await payload.find({
collection: 'articles',
where: {
and: [
{ section: { equals: 'opinion' } },
{ id: { not_equals: currentArticleId } },
],
},
sort: '-publishedDate',
limit: 10,
});

const pool = result.docs as Article[];

// Randomly pick 6 from the pool
const shuffled = pool.sort(() => Math.random() - 0.5);
const picks = shuffled.slice(0, 6);

if (picks.length === 0) return null;

return (
<div className="w-full max-w-[1200px] mx-auto px-4 md:px-6 mt-6 mb-8">
<h2 className="text-[17px] font-bold mb-6">More in Opinion</h2>

<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-x-5 gap-y-8">
{picks.map((article) => {
const dateStr = article.publishedDate || article.createdAt;
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const slug = article.slug || deriveSlug(article.title);
const url = `/opinion/${year}/${month}/${slug}`;
const image = article.featuredImage as Media | null;
const label = opinionTypeLabels[article.opinionType || 'opinion'] || 'Op-Ed';

return (
<Link key={article.id} href={url} className="group flex flex-col">
{image?.url ? (
<div className="relative aspect-[3/2] w-full overflow-hidden bg-gray-100 mb-2">
<Image
src={image.url}
alt={image.alt || article.title}
fill
className="object-cover"
/>
</div>
) : (
<div className="relative aspect-[3/2] w-full bg-gray-200 mb-2" />
)}
<span className="text-[11px] text-gray-500 mb-1">{label}</span>
<h3 className="text-[15px] font-semibold leading-snug text-gray-900 group-hover:text-gray-600 transition-colors">
{article.title}
</h3>
</Link>
);
})}
</div>
</div>
);
};
Loading
Loading