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() { ================================================================== */}
- +
-
- + {!isMobileMenuOpen && (
@@ -80,8 +80,8 @@ export default function Header() {