From 0585749adb22f207b0a55cfa51129a3c80a07373 Mon Sep 17 00:00:00 2001 From: Anthony Santiago Date: Wed, 25 Feb 2026 01:37:43 -0500 Subject: [PATCH 1/3] Added barebones opinion article section and opinion article format w/ dropdown menu, top bar, share button. --- .../[section]/[year]/[month]/[slug]/page.tsx | 54 ++++- app/(frontend)/layout.tsx | 9 +- app/(frontend)/opinion/page.tsx | 162 ++++++++++++++ collections/Articles.ts | 35 ++- collections/Users.ts | 9 +- components/Header.tsx | 48 +++- components/Opinion/OpinionArticleFooter.tsx | 114 ++++++++++ components/Opinion/OpinionArticleGrid.tsx | 145 ++++++++++++ components/Opinion/OpinionArticleHeader.tsx | 112 ++++++++++ components/Opinion/OpinionHeader.tsx | 109 +++++++++ components/Opinion/OpinionPageArticleCard.tsx | 128 +++++++++++ components/Opinion/OpinionScrollBar.tsx | 206 ++++++++++++++++++ components/Opinion/OpinionSubnav.tsx | 161 ++++++++++++++ components/Opinion/types.ts | 23 ++ .../20260220_160000_add_opinion_type.json | 24 ++ .../20260220_160000_add_opinion_type.ts | 29 +++ migrations/20260225_update_opinion_types.ts | 85 ++++++++ migrations/index.ts | 12 + payload-types.ts | 28 +++ utils/deriveSlug.ts | 8 + utils/getArticleUrl.ts | 7 +- 21 files changed, 1490 insertions(+), 18 deletions(-) create mode 100644 app/(frontend)/opinion/page.tsx create mode 100644 components/Opinion/OpinionArticleFooter.tsx create mode 100644 components/Opinion/OpinionArticleGrid.tsx create mode 100644 components/Opinion/OpinionArticleHeader.tsx create mode 100644 components/Opinion/OpinionHeader.tsx create mode 100644 components/Opinion/OpinionPageArticleCard.tsx create mode 100644 components/Opinion/OpinionScrollBar.tsx create mode 100644 components/Opinion/OpinionSubnav.tsx create mode 100644 components/Opinion/types.ts create mode 100644 migrations/20260220_160000_add_opinion_type.json create mode 100644 migrations/20260220_160000_add_opinion_type.ts create mode 100644 migrations/20260225_update_opinion_types.ts create mode 100644 utils/deriveSlug.ts diff --git a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx index b642397..de9adef 100644 --- a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx +++ b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx @@ -3,8 +3,13 @@ import { notFound } from 'next/navigation'; import { getPayload } from 'payload'; import config from '@/payload.config'; import Header from '@/components/Header'; +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 { ArticleHeader, ArticleContent, ArticleFooter } from '@/components/Article'; import * as Photofeature from '@/components/Article/Photofeature'; +import { deriveSlug } from '@/utils/deriveSlug'; export const revalidate = 60; @@ -18,10 +23,11 @@ type Args = { }; export default async function ArticlePage({ params }: Args) { - const { slug } = await params; // We primarily need slug for lookup + 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: { @@ -31,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) { @@ -69,6 +100,25 @@ export default async function ArticlePage({ params }: Args) { ); } + const isOpinion = section === 'opinion'; + + if (isOpinion) { + return ( +
+ + +
+ +
+ + +
+
+ +
+ ); + } + return (
diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx index f72545f..277e727 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"; // START TEMPORARY OVERLAY IMPORT // import AlphaOverlay from "@/components/AlphaOverlay"; @@ -20,6 +20,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", @@ -47,7 +52,7 @@ export default 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 43d7a23..e8edf93 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; import Image from "next/image"; import { ChevronDown, Menu, X, ChevronRight, Search } from "lucide-react"; @@ -81,11 +82,25 @@ export default function Header() { {isMobileMenuOpen && (
)} @@ -146,11 +161,24 @@ export default function Header() {
diff --git a/components/Opinion/OpinionArticleFooter.tsx b/components/Opinion/OpinionArticleFooter.tsx new file mode 100644 index 0000000..7b606a3 --- /dev/null +++ b/components/Opinion/OpinionArticleFooter.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; + +type FooterArticle = { + title: string; + image?: string | null; + url: string; + label?: string; +}; + +const moreInOpinion: FooterArticle[] = [ + { + title: 'RPI Must Do More to Support Student Mental Health', + image: '/placeholder.jpg', + url: '#', + label: 'Op-Ed', + }, + { + title: 'The Case for Open-Source Research at Universities', + image: '/placeholder.jpg', + url: '#', + label: 'Staff Editorial', + }, + { + title: 'Why Greek Life Still Matters on Campus', + image: '/placeholder.jpg', + url: '#', + label: 'Column', + }, + { + title: 'A Letter to the Class of 2029', + image: '/placeholder.jpg', + url: '#', + label: 'Letter to the Editor', + }, + { + title: 'Stop Cutting the Arts — They Make Engineers Better', + image: '/placeholder.jpg', + url: '#', + label: 'Endorsement', + }, + { + title: 'The Dining Hall Deserves a Reckoning', + image: '/placeholder.jpg', + url: '#', + label: 'Top Hat', + }, +]; + +const trendingArticles: FooterArticle[] = [ + { title: 'Student Senate Passes Resolution on Campus Safety Lighting', url: '#' }, + { title: 'Men\'s Hockey Advances to ECAC Quarterfinals', url: '#' }, + { title: 'Spring Career Fair Draws Record Number of Employers', url: '#' }, + { title: 'New Maker Space Opens in the Jonsson Engineering Center', url: '#' }, + { title: 'Faculty Vote to Revise General Education Requirements', url: '#' }, + { title: 'Club Sports Funding Faces Cuts in New Budget Proposal', url: '#' }, + { title: 'A Look Inside the Renovation of the Rensselaer Union', url: '#' }, + { title: 'Campus Shuttle Routes to Change Starting Next Semester', url: '#' }, +]; + +export const OpinionArticleFooter: React.FC = () => { + return ( +
+
+ {/* Left: More in Opinion */} +
+
+

More in Opinion

+ +
+ {moreInOpinion.map((article, idx) => ( + + {article.image && ( +
+
+
+ )} + {article.label && ( + + {article.label} + + )} +

+ {article.title} +

+ + ))} +
+
+ + {/* Right: Trending in The Polytechnic */} +
+
+

Trending in The Polytechnic

+ +
+ {trendingArticles.map((article, idx) => ( + +

+ {article.title} +

+ + ))} +
+
+
+
+ ); +}; diff --git a/components/Opinion/OpinionArticleGrid.tsx b/components/Opinion/OpinionArticleGrid.tsx new file mode 100644 index 0000000..70f422c --- /dev/null +++ b/components/Opinion/OpinionArticleGrid.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import Link from 'next/link'; +import { OpinionArticle } from './types'; +import { OpinionPageArticleCard } from './OpinionPageArticleCard'; + +export const OpinionArticleGrid = ({ + articles, + activeCategory, +}: { + articles: OpinionArticle[]; + activeCategory: string; +}) => { + if (articles.length === 0) { + return ( +
+

+ No articles found in this category. +

+
+ ); + } + + // Split articles into rows: 1 lead, 3 medium, 5 compact, then repeat + const leadArticle = articles[0]; + const threeRow = articles.slice(1, 4); + const fiveRow = articles.slice(4, 9); + const remaining = articles.slice(9); + + return ( +
+ {/* ===== LEAD ARTICLE (1 up) ===== */} +
+ +
+ + {/* Faint divider */} +
+ + {/* ===== THREE ROW ===== */} + {threeRow.length > 0 && ( +
+
+ {threeRow.map((article, idx) => ( +
0 ? 'mt-6 sm:mt-0' : '' + }`} + > + +
+ ))} +
+
+ )} + + {/* Faint divider */} + {fiveRow.length > 0 &&
} + + {/* ===== FIVE ROW ===== */} + {fiveRow.length > 0 && ( +
+
+ {fiveRow.map((article, idx) => ( +
0 ? 'mt-6 lg:mt-0' : '' + }`} + > + +
+ ))} +
+
+ )} + + {/* ===== REMAINING ARTICLES (repeat pattern) ===== */} + {remaining.length > 0 && ( + <> +
+
+
+ {remaining.map((article, idx) => ( +
= 3 ? 'mt-8 sm:mt-6 lg:mt-8 pt-6 lg:pt-8 border-t border-gray-200 sm:border-t-0 lg:border-t' : '' + } ${ + idx > 0 && idx < 3 ? 'mt-6 sm:mt-0' : '' + }`} + > + +
+ ))} +
+
+ + )} + + {/* Sidebar boxes */} +
+
+
+

+ What should The Polytechnic write about? +

+

+ Help us prioritize topics for future opinion pieces. +

+ +
+
+

+ Interested in submitting an op-ed?{' '} + + Learn More + +

+
+
+
+
+ ); +}; diff --git a/components/Opinion/OpinionArticleHeader.tsx b/components/Opinion/OpinionArticleHeader.tsx new file mode 100644 index 0000000..53b7909 --- /dev/null +++ b/components/Opinion/OpinionArticleHeader.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import Image from 'next/image'; +import { Article, Media, User } from '@/payload-types'; + +const opinionTypeLabels: Record = { + opinion: 'Op-Ed', + column: 'Column', + 'staff-editorial': 'Staff Editorial', + 'editorial-notebook': 'Editorial Notebook', + endorsement: 'Endorsement', + 'top-hat': 'Top Hat', + 'candidate-profile': 'Candidate Profile', + 'letter-to-the-editor': 'Letter to the Editor', + 'polys-recommendations': "The Poly's Recommendations", + other: 'Other', +}; + +type Props = { + article: Article; +}; + +export const OpinionArticleHeader: React.FC = ({ article }) => { + const featuredImage = article.featuredImage as Media | null; + const typeLabel = opinionTypeLabels[article.opinionType || 'opinion'] || 'Op-Ed'; + + return ( +
+ {/* Kicker + Opinion Type */} +
+ + Opinion + + + {typeLabel} + +
+ + {/* Faint divider line */} +
+ + {/* Title */} +

+ {article.title} +

+ + {/* Date */} +
+ {article.publishedDate + ? new Date(article.publishedDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : ''} +
+ + {/* Featured Image + Caption */} + {featuredImage?.url && ( +
+
+ {featuredImage.alt +
+

+ {article.imageCaption || 'Illustration by The Polytechnic'} +

+
+ )} + + {/* Author byline */} +
+
+
+ By {article.authors && article.authors.length > 0 ? ( + article.authors.map((author, index) => { + const user = author as User; + return ( + + {index > 0 && index === article.authors!.length - 1 ? ' and ' : index > 0 ? ', ' : ''} + {user.firstName} {user.lastName} + + ); + }) + ) : ( + 'The Poly Staff' + )} +
+ {article.authors && article.authors.length > 0 && ( +

+ {article.authors.map((author, index) => { + const user = author as User; + const oneLiner = (user as any).oneLiner || `is a contributor to The Polytechnic`; + return ( + + {index > 0 && '. '} + {user.firstName} {user.lastName} {oneLiner} + + ); + })}. +

+ )} +
+
+
+ ); +}; diff --git a/components/Opinion/OpinionHeader.tsx b/components/Opinion/OpinionHeader.tsx new file mode 100644 index 0000000..1b51346 --- /dev/null +++ b/components/Opinion/OpinionHeader.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Menu, X, Search, ChevronRight } from 'lucide-react'; + +const navItems = ['News', 'Features', 'Opinion', 'Sports', 'Editorial', 'Checkmate', 'Archives', 'About']; + +export default function OpinionHeader() { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + + + return ( +
+
+ {/* Left: Hamburger menu */} + + + {/* Center: Logo (absolutely centered) */} + +
+ The Polytechnic +
+ + + {/* Right: Search */} + +
+ + {/* Sidebar menu */} + {isMobileMenuOpen && ( + <> +
setIsMobileMenuOpen(false)} + /> +
+ {/* Close button */} +
+ +
+ + +
+ + )} +
+ ); +} diff --git a/components/Opinion/OpinionPageArticleCard.tsx b/components/Opinion/OpinionPageArticleCard.tsx new file mode 100644 index 0000000..b4332d5 --- /dev/null +++ b/components/Opinion/OpinionPageArticleCard.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { OpinionArticle } from './types'; +import { getArticleUrl } from '@/utils/getArticleUrl'; + +const opinionTypeLabels: Record = { + opinion: 'Op-Ed', + column: 'Column', + 'staff-editorial': 'Staff Editorial', + 'editorial-notebook': 'Editorial Notebook', + endorsement: 'Endorsement', + 'top-hat': 'Top Hat', + 'candidate-profile': 'Candidate Profile', + 'letter-to-the-editor': 'Letter to the Editor', + 'polys-recommendations': "The Poly's Recommendations", + other: 'Other', +}; + +export const OpinionPageArticleCard = ({ + article, + variant = 'compact', +}: { + article: OpinionArticle; + variant?: 'lead' | 'medium' | 'compact'; +}) => { + const url = getArticleUrl(article); + const typeLabel = opinionTypeLabels[article.opinionType || 'opinion'] || 'Opinion'; + + // Lead variant: text on left, large image on right (NYT hero style) + if (variant === 'lead') { + return ( + + {/* Left: text content */} +
+ + {typeLabel} + +

+ {article.title} +

+ {article.excerpt && ( +

+ {article.excerpt} +

+ )} + {article.author && ( +

+ By {article.author} +

+ )} +
+ {/* Right: image */} + {article.image && ( +
+ {article.title} +
+ )} + + ); + } + + // Medium variant: image on top, text below (NYT 3-column row) + if (variant === 'medium') { + return ( + + {article.image && ( +
+ {article.title} +
+ )} + + {typeLabel} + +

+ {article.title} +

+ {article.excerpt && ( +

+ {article.excerpt} +

+ )} + {article.author && ( +

+ By {article.author} +

+ )} + + ); + } + + // Compact variant: small text-only card (NYT 5-column row) + return ( + + {article.image && ( +
+ {article.title} +
+ )} + + {typeLabel} + +

+ {article.title} +

+ {article.author && ( +

+ By {article.author} +

+ )} + + ); +}; diff --git a/components/Opinion/OpinionScrollBar.tsx b/components/Opinion/OpinionScrollBar.tsx new file mode 100644 index 0000000..2a91cb2 --- /dev/null +++ b/components/Opinion/OpinionScrollBar.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; + +type Props = { + title: string; +}; + +const shareOptions = [ + { label: 'Copy link', icon: 'link' }, + { label: 'Email', icon: 'email' }, + { label: 'Facebook', icon: 'facebook' }, + { label: 'X', icon: 'x' }, + { label: 'LinkedIn', icon: 'linkedin' }, + { label: 'WhatsApp', icon: 'whatsapp' }, + { label: 'Reddit', icon: 'reddit' }, +]; + +function ShareIcon({ type, className }: { type: string; className?: string }) { + const cls = className || 'w-5 h-5'; + switch (type) { + case 'link': + return ( + + + + + ); + case 'email': + return ( + + + + + ); + case 'facebook': + return ( + + + + ); + case 'bluesky': + return ( + + + + ); + case 'x': + return ( + + + + ); + case 'linkedin': + return ( + + + + ); + case 'whatsapp': + return ( + + + + ); + case 'reddit': + return ( + + + + ); + default: + return null; + } +} + +export default function OpinionScrollBar({ title }: Props) { + const [visible, setVisible] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [copied, setCopied] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleScroll = () => { + setVisible(window.scrollY > 200); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShareOpen(false); + } + }; + if (shareOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [shareOpen]); + + const handleShare = (type: string) => { + const url = window.location.href; + const text = title; + + switch (type) { + case 'link': + navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + return; + case 'email': + window.open(`mailto:?subject=${encodeURIComponent(text)}&body=${encodeURIComponent(url)}`); + break; + case 'facebook': + window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`); + break; + case 'bluesky': + window.open(`https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + url)}`); + break; + case 'x': + window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); + break; + case 'linkedin': + window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`); + break; + case 'whatsapp': + window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`); + break; + case 'reddit': + window.open(`https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(text)}`); + break; + } + setShareOpen(false); + }; + + return ( +
+
+ {/* Left: Logo */} + +
+ The Polytechnic +
+ + + {/* Center: OPINION | Title (absolutely centered) */} +
+ Opinion + | + {title} +
+ + {/* Right: Share button + dropdown */} +
+ + + {shareOpen && ( +
+ {/* Arrow pointing up toward button */} +
+
+
+

Share options

+
+
+ {shareOptions.map((opt, i) => ( +
+ {i > 0 &&
} + +
+ ))} +
+
+
+ )} +
+
+
+ ); +} diff --git a/components/Opinion/OpinionSubnav.tsx b/components/Opinion/OpinionSubnav.tsx new file mode 100644 index 0000000..b615792 --- /dev/null +++ b/components/Opinion/OpinionSubnav.tsx @@ -0,0 +1,161 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ChevronDown } from 'lucide-react'; +import { ColumnistAuthor } from './types'; + +const tabs = [ + { label: 'Columns', value: 'column', isDropdown: true }, + { label: 'Op-Eds', value: 'opinion', isDropdown: false }, + { label: 'Staff Editorials', value: 'staff-editorial', isDropdown: false }, + { label: 'Editorial Notebook', value: 'editorial-notebook', isDropdown: false }, + { label: 'Letters', value: 'letter-to-the-editor', isDropdown: false }, + { label: 'More', value: 'all-more', isDropdown: true }, +]; + +const moreOptions = [ + { label: 'Endorsement', value: 'endorsement' }, + { label: 'Top Hat', value: 'top-hat' }, + { label: 'Candidate Profile', value: 'candidate-profile' }, + { label: "The Poly's Recommendations", value: 'polys-recommendations' }, + { label: 'Other', value: 'other' }, +]; + +export const OpinionSubnav = ({ + activeCategory, + columnists, +}: { + activeCategory: string; + columnists: ColumnistAuthor[]; +}) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const [columnistsOpen, setColumnistsOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + const columnistsDropdownRef = useRef(null); + const moreDropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + columnistsDropdownRef.current && + !columnistsDropdownRef.current.contains(e.target as Node) + ) { + setColumnistsOpen(false); + } + if ( + moreDropdownRef.current && + !moreDropdownRef.current.contains(e.target as Node) + ) { + setMoreOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const pushCategory = (category: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('category', category); + router.push(`/opinion?${params.toString()}`); + setColumnistsOpen(false); + setMoreOpen(false); + }; + + const tabClass = (isActive: boolean) => + `text-[13px] font-medium uppercase tracking-wide whitespace-nowrap transition-colors flex items-center gap-1.5 ${ + isActive ? 'text-[#D6001C]' : 'text-black hover:text-[#D6001C]' + }`; + + const divider = ( + | + ); + + return ( + + ); +}; diff --git a/components/Opinion/types.ts b/components/Opinion/types.ts new file mode 100644 index 0000000..8d42377 --- /dev/null +++ b/components/Opinion/types.ts @@ -0,0 +1,23 @@ +export interface OpinionArticle { + id: number | string; + slug: string; + title: string; + excerpt: string; + author: string | null; + authorId: number | null; + authorHeadshot: string | null; + date: string | null; + publishedDate: string | null; + createdAt: string; + image: string | null; + section: string; + opinionType: string | null; +} + +export interface ColumnistAuthor { + id: number; + firstName: string; + lastName: string; + headshot: string | null; + latestArticleUrl: string | null; +} diff --git a/migrations/20260220_160000_add_opinion_type.json b/migrations/20260220_160000_add_opinion_type.json new file mode 100644 index 0000000..d343ded --- /dev/null +++ b/migrations/20260220_160000_add_opinion_type.json @@ -0,0 +1,24 @@ +{ + "version": "5.4.14", + "dialect": "postgres", + "tables": { + "articles": { + "columns": [ + { + "name": "opinion_type", + "type": "text", + "notNull": false + } + ] + }, + "_articles_v": { + "columns": [ + { + "name": "version_opinion_type", + "type": "text", + "notNull": false + } + ] + } + } +} diff --git a/migrations/20260220_160000_add_opinion_type.ts b/migrations/20260220_160000_add_opinion_type.ts new file mode 100644 index 0000000..525f9b7 --- /dev/null +++ b/migrations/20260220_160000_add_opinion_type.ts @@ -0,0 +1,29 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.execute(sql` + CREATE TYPE "public"."opinion_type_enum" AS ENUM('opinion', 'columnist', 'staff-editorial', 'editorial-notebook', 'special-edition', 'more'); + + ALTER TABLE "articles" ADD COLUMN "opinion_type" "opinion_type_enum"; + + ALTER TABLE "_articles_v" ADD COLUMN "version_opinion_type" "opinion_type_enum"; + + CREATE INDEX "articles_opinion_type_idx" ON "articles" USING btree ("opinion_type"); + + CREATE INDEX "_articles_v_version_opinion_type_idx" ON "_articles_v" USING btree ("version_opinion_type"); + `) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + DROP INDEX IF EXISTS "_articles_v_version_opinion_type_idx"; + + DROP INDEX IF EXISTS "articles_opinion_type_idx"; + + ALTER TABLE "_articles_v" DROP COLUMN IF EXISTS "version_opinion_type"; + + ALTER TABLE "articles" DROP COLUMN IF EXISTS "opinion_type"; + + DROP TYPE IF EXISTS "public"."opinion_type_enum"; + `) +} diff --git a/migrations/20260225_update_opinion_types.ts b/migrations/20260225_update_opinion_types.ts new file mode 100644 index 0000000..e9a6ed0 --- /dev/null +++ b/migrations/20260225_update_opinion_types.ts @@ -0,0 +1,85 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + // The original migration created a custom enum "opinion_type_enum". + // Payload expects enums named "enum_articles_opinion_type" and "enum__articles_v_version_opinion_type". + // We need to: 1) create the new enums with all values, 2) migrate columns, 3) drop old enum. + + // Step 1: Create the Payload-standard enum types with all new values + await db.execute(sql` + CREATE TYPE "public"."enum_articles_opinion_type" AS ENUM( + 'opinion', 'column', 'staff-editorial', 'editorial-notebook', + 'endorsement', 'top-hat', 'candidate-profile', 'letter-to-the-editor', + 'polys-recommendations', 'other' + ); + `) + + await db.execute(sql` + CREATE TYPE "public"."enum__articles_v_version_opinion_type" AS ENUM( + 'opinion', 'column', 'staff-editorial', 'editorial-notebook', + 'endorsement', 'top-hat', 'candidate-profile', 'letter-to-the-editor', + 'polys-recommendations', 'other' + ); + `) + + // Step 2: Migrate old values before changing the column type + await db.execute(sql` + UPDATE "articles" SET "opinion_type" = 'column' WHERE "opinion_type" = 'columnist'; + UPDATE "articles" SET "opinion_type" = 'other' WHERE "opinion_type" IN ('more', 'special-edition'); + `) + + await db.execute(sql` + UPDATE "_articles_v" SET "version_opinion_type" = 'column' WHERE "version_opinion_type" = 'columnist'; + UPDATE "_articles_v" SET "version_opinion_type" = 'other' WHERE "version_opinion_type" IN ('more', 'special-edition'); + `) + + // Step 3: Alter columns to use the new enum types + await db.execute(sql` + ALTER TABLE "articles" + ALTER COLUMN "opinion_type" SET DATA TYPE "public"."enum_articles_opinion_type" + USING "opinion_type"::text::"public"."enum_articles_opinion_type"; + `) + + await db.execute(sql` + ALTER TABLE "_articles_v" + ALTER COLUMN "version_opinion_type" SET DATA TYPE "public"."enum__articles_v_version_opinion_type" + USING "version_opinion_type"::text::"public"."enum__articles_v_version_opinion_type"; + `) + + // Step 4: Drop the old custom enum + await db.execute(sql` + DROP TYPE IF EXISTS "public"."opinion_type_enum"; + `) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + // Recreate old enum + await db.execute(sql` + CREATE TYPE "public"."opinion_type_enum" AS ENUM('opinion', 'columnist', 'staff-editorial', 'editorial-notebook', 'special-edition', 'more'); + `) + + // Revert data + await db.execute(sql` + UPDATE "articles" SET "opinion_type" = 'columnist' WHERE "opinion_type" = 'column'; + UPDATE "articles" SET "opinion_type" = 'more' WHERE "opinion_type" = 'other'; + UPDATE "_articles_v" SET "version_opinion_type" = 'columnist' WHERE "version_opinion_type" = 'column'; + UPDATE "_articles_v" SET "version_opinion_type" = 'more' WHERE "version_opinion_type" = 'other'; + `) + + // Revert columns to old enum + await db.execute(sql` + ALTER TABLE "articles" + ALTER COLUMN "opinion_type" SET DATA TYPE "public"."opinion_type_enum" + USING "opinion_type"::text::"public"."opinion_type_enum"; + + ALTER TABLE "_articles_v" + ALTER COLUMN "version_opinion_type" SET DATA TYPE "public"."opinion_type_enum" + USING "version_opinion_type"::text::"public"."opinion_type_enum"; + `) + + // Drop new enums + await db.execute(sql` + DROP TYPE IF EXISTS "public"."enum__articles_v_version_opinion_type"; + DROP TYPE IF EXISTS "public"."enum_articles_opinion_type"; + `) +} diff --git a/migrations/index.ts b/migrations/index.ts index fc1fe7a..e9a8a9b 100644 --- a/migrations/index.ts +++ b/migrations/index.ts @@ -1,5 +1,7 @@ import * as migration_20260211_224237_initial_baseline from './20260211_224237_initial_baseline'; import * as migration_20260213_223303_remove_copy_workflow from './20260213_223303_remove_copy_workflow'; +import * as migration_20260220_160000_add_opinion_type from './20260220_160000_add_opinion_type'; +import * as migration_20260225_update_opinion_types from './20260225_update_opinion_types'; export const migrations = [ { @@ -12,4 +14,14 @@ export const migrations = [ down: migration_20260213_223303_remove_copy_workflow.down, name: '20260213_223303_remove_copy_workflow' }, + { + up: migration_20260220_160000_add_opinion_type.up, + down: migration_20260220_160000_add_opinion_type.down, + name: '20260220_160000_add_opinion_type' + }, + { + up: migration_20260225_update_opinion_types.up, + down: migration_20260225_update_opinion_types.down, + name: '20260225_update_opinion_types' + }, ]; diff --git a/payload-types.ts b/payload-types.ts index 4028506..5c70e71 100644 --- a/payload-types.ts +++ b/payload-types.ts @@ -130,6 +130,10 @@ export interface User { lastName: string; roles?: ('admin' | 'eic' | 'editor' | 'writer')[] | null; headshot?: (number | null) | Media; + /** + * A short one-line description (e.g. "is a senior studying computer science") + */ + oneLiner?: string | null; bio?: { root: { type: string; @@ -212,9 +216,30 @@ export interface Article { kicker?: string | null; subdeck?: string | null; section: 'news' | 'sports' | 'features' | 'editorial' | 'opinion'; + /** + * Categorizes opinion articles. Only visible when section is Opinion. + */ + opinionType?: + | ( + | 'opinion' + | 'column' + | 'staff-editorial' + | 'editorial-notebook' + | 'endorsement' + | 'top-hat' + | 'candidate-profile' + | 'letter-to-the-editor' + | 'polys-recommendations' + | 'other' + ) + | null; authors: (number | User)[]; publishedDate?: string | null; featuredImage?: (number | null) | Media; + /** + * Caption for the featured image (e.g. "Illustration by The Polytechnic") + */ + imageCaption?: string | null; content?: { root: { type: string; @@ -349,6 +374,7 @@ export interface UsersSelect { lastName?: T; roles?: T; headshot?: T; + oneLiner?: T; bio?: T; positions?: | T @@ -403,9 +429,11 @@ export interface ArticlesSelect { kicker?: T; subdeck?: T; section?: T; + opinionType?: T; authors?: T; publishedDate?: T; featuredImage?: T; + imageCaption?: T; content?: T; slug?: T; updatedAt?: T; diff --git a/utils/deriveSlug.ts b/utils/deriveSlug.ts new file mode 100644 index 0000000..b18ff0d --- /dev/null +++ b/utils/deriveSlug.ts @@ -0,0 +1,8 @@ +export const deriveSlug = (title: string): string => { + return title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} diff --git a/utils/getArticleUrl.ts b/utils/getArticleUrl.ts index 78edd2a..f4223a5 100644 --- a/utils/getArticleUrl.ts +++ b/utils/getArticleUrl.ts @@ -1,5 +1,8 @@ +import { deriveSlug } from './deriveSlug'; + type ArticleLike = { section: string; + title?: string; slug?: string | null; publishedDate?: string | null; createdAt?: string; @@ -11,7 +14,7 @@ export const getArticleUrl = (article: ArticleLike) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const section = article.section; - const slug = article.slug; - + const slug = article.slug || (article.title ? deriveSlug(article.title) : 'untitled'); + return `/${section}/${year}/${month}/${slug}`; } From 77f982b42cfb5fae428136d29b3f0210829e31d3 Mon Sep 17 00:00:00 2001 From: Anthony Santiago Date: Wed, 25 Feb 2026 02:03:42 -0500 Subject: [PATCH 2/3] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- components/Opinion/OpinionArticleFooter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/Opinion/OpinionArticleFooter.tsx b/components/Opinion/OpinionArticleFooter.tsx index 7b606a3..b5bc489 100644 --- a/components/Opinion/OpinionArticleFooter.tsx +++ b/components/Opinion/OpinionArticleFooter.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import Image from 'next/image'; import Link from 'next/link'; type FooterArticle = { From 2a649b3869b3340a0342de0b4957b89271d63b54 Mon Sep 17 00:00:00 2001 From: Anthony Santiago Date: Wed, 25 Feb 2026 02:14:23 -0500 Subject: [PATCH 3/3] Fix grey text in opinion articles, override to black --- app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx index 75ab987..debd965 100644 --- a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx +++ b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx @@ -99,7 +99,7 @@ export default async function ArticlePage({ params }: Args) {
-
+