diff --git a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx
index 53c957a..debd965 100644
--- a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx
+++ b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx
@@ -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;
@@ -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: {
@@ -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) {
@@ -59,6 +91,24 @@ export default async function ArticlePage({ params }: Args) {
}
}
+ // Opinion articles get their own custom layout
+ if (section === 'opinion' && layoutType !== 'photofeature') {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
return ;
}
@@ -82,7 +132,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,
@@ -90,4 +140,4 @@ export async function generateStaticParams() {
slug: doc.slug as string,
};
});
-}
\ No newline at end of file
+}
diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx
index b1eb422..c64823a 100644
--- a/app/(frontend)/layout.tsx
+++ b/app/(frontend)/layout.tsx
@@ -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";
@@ -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",
@@ -55,7 +60,7 @@ export default async function RootLayout({
return (
{/* START TEMPORARY OVERLAY: Remove this component when alpha is over */}
{/* */}
diff --git a/app/(frontend)/opinion/page.tsx b/app/(frontend)/opinion/page.tsx
new file mode 100644
index 0000000..7b44313
--- /dev/null
+++ b/app/(frontend)/opinion/page.tsx
@@ -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();
+
+ 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 (
+
+
+
+ {/* Opinion masthead */}
+
+
+ Opinion
+
+
+
+ {/* Subheader nav with dropdowns */}
+ }
+ >
+
+
+
+ {/* Article grid */}
+
+
+
+
+ );
+}
diff --git a/collections/Articles.ts b/collections/Articles.ts
index 95da813..b0c7664 100644
--- a/collections/Articles.ts
+++ b/collections/Articles.ts
@@ -1,4 +1,5 @@
import type { CollectionConfig } from 'payload'
+import { deriveSlug } from '../utils/deriveSlug'
const Articles: CollectionConfig = {
slug: 'articles',
@@ -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'
@@ -56,7 +62,7 @@ const Articles: CollectionConfig = {
type: 'textarea',
},
{
- name: 'section',
+ name: 'section',
type: 'select',
options: [
{ label: 'News', value: 'news' },
@@ -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',
@@ -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',
diff --git a/collections/Users.ts b/collections/Users.ts
index d142a30..0af265f 100644
--- a/collections/Users.ts
+++ b/collections/Users.ts
@@ -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',
diff --git a/components/Header.tsx b/components/Header.tsx
index 3908929..3d96e3f 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -1,8 +1,8 @@
"use client";
import { useState, useEffect } from "react";
-import Image from "next/image";
import Link from "next/link";
+import Image from "next/image";
import { ChevronDown, Menu, ChevronRight, Search } from "lucide-react";
export default function Header() {
@@ -34,9 +34,9 @@ export default function Header() {
================================================================== */}