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
58 changes: 54 additions & 4 deletions app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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 { deriveSlug } from '@/utils/deriveSlug';

export const revalidate = 60;

Expand All @@ -17,10 +23,11 @@ type Args = {
};

export default async function ArticlePage({ params }: Args) {
const { slug } = await params;
const { section, slug } = await params;
const payload = await getPayload({ config });

const result = await payload.find({
// Try finding by slug first
let result = await payload.find({
collection: 'articles',
where: {
slug: {
Expand All @@ -30,6 +37,31 @@ export default async function ArticlePage({ params }: Args) {
limit: 1,
});

// If no result, try matching by title (for articles without saved slugs)
if (result.docs.length === 0) {
const allInSection = await payload.find({
collection: 'articles',
where: {
section: { equals: section },
},
limit: 200,
});

const match = allInSection.docs.find((doc) => {
return deriveSlug(doc.title) === slug;
});

if (match) {
// Save the slug so future lookups work directly
await payload.update({
collection: 'articles',
id: match.id,
data: { slug },
});
result = { ...result, docs: [match] };
}
}

const article = result.docs[0];

if (!article) {
Expand Down Expand Up @@ -59,6 +91,24 @@ export default async function ArticlePage({ params }: Args) {
}
}

// 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]">
<ArticleContent content={article.content} />
<ArticleFooter />
</div>
</article>
<OpinionArticleFooter />
</main>
);
}

return <LayoutComponent article={article} content={cleanContent} />;
}

Expand All @@ -82,12 +132,12 @@ 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,
month,
slug: doc.slug as string,
};
});
}
}
9 changes: 7 additions & 2 deletions app/(frontend)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Cinzel } from "next/font/google";
import { Geist, Geist_Mono, Cinzel, Raleway } from "next/font/google";
import "./globals.css";
import ThemeProvider from "@/components/ThemeProvider";
import { cookies } from "next/headers";
Expand All @@ -22,6 +22,11 @@ const cinzel = Cinzel({
subsets: ["latin"],
});

const raleway = Raleway({
variable: "--font-raleway",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "The Polytechnic",
description: "Serving Rensselaer Since 1885",
Expand Down Expand Up @@ -55,7 +60,7 @@ export default async function RootLayout({
return (
<html lang="en" className={isDarkMode ? "dark" : ""}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${cinzel.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${cinzel.variable} ${raleway.variable} antialiased`}
>
{/* START TEMPORARY OVERLAY: Remove this component when alpha is over */}
{/* <AlphaOverlay /> */}
Expand Down
162 changes: 162 additions & 0 deletions app/(frontend)/opinion/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { Suspense } from 'react';
import { getPayload } from 'payload';
import config from '@/payload.config';
import OpinionHeader from '@/components/Opinion/OpinionHeader';
import { OpinionSubnav } from '@/components/Opinion/OpinionSubnav';
import { OpinionArticleGrid } from '@/components/Opinion/OpinionArticleGrid';
import { OpinionArticle, ColumnistAuthor } from '@/components/Opinion/types';
import { getArticleUrl } from '@/utils/getArticleUrl';
import { Article as PayloadArticle, Media } from '@/payload-types';

export const revalidate = 60;

type Props = {
searchParams: Promise<{ category?: string }>;
};

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;

const date = article.publishedDate ? new Date(article.publishedDate) : null;
let dateString: string | null = null;

if (date) {
const diffMins = Math.floor((Date.now() - date.getTime()) / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);

if (diffMins < 60)
dateString = `${diffMins} MINUTE${diffMins !== 1 ? 'S' : ''} AGO`;
else if (diffHours < 24)
dateString = `${diffHours} HOUR${diffHours !== 1 ? 'S' : ''} AGO`;
else if (diffDays < 7)
dateString = `${diffDays} DAY${diffDays !== 1 ? 'S' : ''} AGO`;
}

// Get first author's headshot for columnist info
let authorHeadshot: string | null = null;
let authorId: number | null = null;

if (article.authors && article.authors.length > 0) {
const firstAuthor = article.authors[0];
if (typeof firstAuthor !== 'number') {
authorId = firstAuthor.id;
if (firstAuthor.headshot && typeof firstAuthor.headshot === 'object') {
authorHeadshot = (firstAuthor.headshot as Media).url || 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,
authorHeadshot,
date: dateString,
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({ searchParams }: Props) {
const { category } = await searchParams;
const payload = await getPayload({ config });

// Fetch all published opinion articles
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);

// Derive columnists from articles with opinionType === 'column'
const columnistArticles = articles.filter(
(a) => a.opinionType === 'column'
);
const columnistMap = new Map<number, ColumnistAuthor>();

columnistArticles.forEach((article) => {
const originalArticle = result.docs.find((doc) => doc.id === article.id);
if (originalArticle && originalArticle.authors) {
originalArticle.authors.forEach((author) => {
if (typeof author === 'number') return;

if (!columnistMap.has(author.id)) {
columnistMap.set(author.id, {
id: author.id,
firstName: author.firstName,
lastName: author.lastName,
headshot: (author.headshot as Media)?.url || null,
latestArticleUrl: getArticleUrl({
section: originalArticle.section,
slug: originalArticle.slug || '#',
publishedDate: originalArticle.publishedDate,
createdAt: originalArticle.createdAt,
}),
});
}
});
}
});

const columnists = Array.from(columnistMap.values()).sort((a, b) =>
`${a.firstName} ${a.lastName}`.localeCompare(
`${b.firstName} ${b.lastName}`
)
);

// Filter articles by category
const filtered =
category && category !== 'all'
? articles.filter((a) => a.opinionType === category)
: articles;

return (
<main className="min-h-screen bg-white pt-[58px]">
<OpinionHeader />

{/* Opinion masthead */}
<div className="max-w-[1280px] mx-auto px-4 md:px-6 mt-6 md:mt-8 mb-2">
<h1 className="text-4xl md:text-5xl font-normal tracking-tight text-gray-900">
Opinion
</h1>
</div>

{/* Subheader nav with dropdowns */}
<Suspense
fallback={<div className="h-[49px] border-b border-black bg-white" />}
>
<OpinionSubnav activeCategory={category || 'all'} columnists={columnists} />
</Suspense>

{/* Article grid */}
<div className="max-w-[1280px] mx-auto px-4 md:px-6 py-12">
<OpinionArticleGrid articles={filtered} activeCategory={category || 'all'} />
</div>
</main>
);
}
35 changes: 34 additions & 1 deletion 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'

const Articles: CollectionConfig = {
slug: 'articles',
Expand Down Expand Up @@ -32,6 +33,11 @@ const Articles: CollectionConfig = {
hooks: {
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 All @@ -56,7 +62,7 @@ const Articles: CollectionConfig = {
type: 'textarea',
},
{
name: 'section',
name: 'section',
type: 'select',
options: [
{ label: 'News', value: 'news' },
Expand All @@ -67,6 +73,26 @@ const Articles: CollectionConfig = {
],
required: true,
},
{
name: 'opinionType',
type: 'select',
options: [
{ label: 'Op-Ed', 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: 'Other', value: 'other' },
],
admin: {
condition: (data) => data?.section === 'opinion',
description: 'Categorizes opinion articles. Only visible when section is Opinion.',
},
},
{
name: 'authors',
type: 'relationship',
Expand Down Expand Up @@ -94,6 +120,13 @@ const Articles: CollectionConfig = {
type: 'upload',
relationTo: 'media',
},
{
name: 'imageCaption',
type: 'text',
admin: {
description: 'Caption for the featured image (e.g. "Illustration by The Polytechnic")',
},
},
{
name: 'content',
type: 'richText',
Expand Down
9 changes: 8 additions & 1 deletion collections/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,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
Loading