diff --git a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx index a092bd5..9329764 100644 --- a/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx +++ b/app/(frontend)/[section]/[year]/[month]/[slug]/page.tsx @@ -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'; @@ -207,7 +212,6 @@ export default async function ArticlePage({ params }: Args) { notFound(); } - // Determine the layout const layoutType = getArticleLayout(article); const LayoutComponent = ArticleLayouts[layoutType]; @@ -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 ( +
+ + +
+ +
+ + +
+
+ +
+ ); } const staffAuthorsForJsonLd = (article.authors || []).filter((author): author is User => typeof author !== 'number'); @@ -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 }, ], }; @@ -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: [ @@ -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, diff --git a/app/(frontend)/opinion/page.tsx b/app/(frontend)/opinion/page.tsx new file mode 100644 index 0000000..96cbf3e --- /dev/null +++ b/app/(frontend)/opinion/page.tsx @@ -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 ( +
+ +
+ +
+
+ ); +} diff --git a/collections/Articles.ts b/collections/Articles.ts index 76d6880..1ee8bd7 100644 --- a/collections/Articles.ts +++ b/collections/Articles.ts @@ -1,4 +1,5 @@ import type { CollectionConfig } from 'payload' +import { deriveSlug } from '../utils/deriveSlug' import { getPostHogClient } from '../lib/posthog-server' const Articles: CollectionConfig = { @@ -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' @@ -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', diff --git a/collections/Users.ts b/collections/Users.ts index 171a3b3..6ce171d 100644 --- a/collections/Users.ts +++ b/collections/Users.ts @@ -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', diff --git a/components/Opinion/OpinionArticleFooter.tsx b/components/Opinion/OpinionArticleFooter.tsx new file mode 100644 index 0000000..f371385 --- /dev/null +++ b/components/Opinion/OpinionArticleFooter.tsx @@ -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 = 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 ( +
+

More in Opinion

+ +
+ {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 ( + + {image?.url ? ( +
+ {image.alt +
+ ) : ( +
+ )} + {label} +

+ {article.title} +

+ + ); + })} +
+
+ ); +}; diff --git a/components/Opinion/OpinionArticleGrid.tsx b/components/Opinion/OpinionArticleGrid.tsx new file mode 100644 index 0000000..457bcdc --- /dev/null +++ b/components/Opinion/OpinionArticleGrid.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { OpinionArticle } from './types'; +import { OpinionPageArticleCard } from './OpinionPageArticleCard'; + +const editorialTypes = new Set(['staff-editorial', 'editorial-notebook', 'editors-notebook']); + +function SectionRow({ title, articles }: { title: string; articles: OpinionArticle[] }) { + if (articles.length === 0) return null; + return ( +
+
+

+ {title} +

+
+
+
+ {articles.map((article, idx) => ( +
+ +
+ ))} +
+
+ ); +} + +export const OpinionArticleGrid = ({ articles }: { articles: OpinionArticle[] }) => { + if (articles.length === 0) { + return ( +
+

No articles found.

+
+ ); + } + + const top3 = articles.slice(0, 3); + const next5 = articles.slice(3, 8); + const shownIds = new Set(articles.slice(0, 8).map((a) => a.id)); + const remaining = articles.filter((a) => !shownIds.has(a.id)); + + const opinionSection = remaining + .filter((a) => a.opinionType === 'opinion' || !a.opinionType) + .slice(0, 5); + const opinionIds = new Set(opinionSection.map((a) => a.id)); + + const editorialSection = remaining + .filter((a) => editorialTypes.has(a.opinionType || '')) + .slice(0, 5); + const editorialIds = new Set(editorialSection.map((a) => a.id)); + + const moreSection = remaining + .filter((a) => !opinionIds.has(a.id) && !editorialIds.has(a.id)) + .slice(0, 5); + + return ( +
+ {/* Section header */} +
+

Opinion

+
+
+ + {/* Top 3 with images */} +
+ {top3.map((article, idx) => ( +
+ +
+ ))} +
+ + {/* Divider */} + {next5.length > 0 &&
} + + {/* 5 smaller articles */} + {next5.length > 0 && ( +
+ {next5.map((article, idx) => ( +
+ +
+ ))} +
+ )} + + {/* Divider before section rows */} + {(opinionSection.length > 0 || editorialSection.length > 0 || moreSection.length > 0) && ( +
+ )} + + {/* Section rows */} +
+ + + +
+
+ ); +}; diff --git a/components/Opinion/OpinionHeader.tsx b/components/Opinion/OpinionHeader.tsx new file mode 100644 index 0000000..1e0e966 --- /dev/null +++ b/components/Opinion/OpinionHeader.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import SearchOverlay from '@/components/SearchOverlay'; + +export default function OpinionHeader() { + return ( +
+
+ {/* Center: Logo (absolutely centered) */} + +
+ The Polytechnic +
+ + + {/* Right: Search */} + +
+
+ ); +} diff --git a/components/Opinion/OpinionPageArticleCard.tsx b/components/Opinion/OpinionPageArticleCard.tsx new file mode 100644 index 0000000..4e1c032 --- /dev/null +++ b/components/Opinion/OpinionPageArticleCard.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { OpinionArticle } from './types'; +import { getArticleUrl } from '@/utils/getArticleUrl'; +import { opinionTypeLabels } from './opinionTypeLabels'; + +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..cab6cd0 --- /dev/null +++ b/components/Opinion/OpinionScrollBar.tsx @@ -0,0 +1,213 @@ +'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(prev => { + if (!prev && window.scrollY > 400) return true; + if (prev && window.scrollY < 300) return false; + return prev; + }); + }; + 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/20260211_224237_initial_baseline.ts b/migrations/20260211_224237_initial_baseline.ts index 20ed811..f37592a 100644 --- a/migrations/20260211_224237_initial_baseline.ts +++ b/migrations/20260211_224237_initial_baseline.ts @@ -1,6 +1,6 @@ import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' -export async function up({ db }: MigrateUpArgs): Promise { +export async function up({ db, payload, req }: MigrateUpArgs): Promise { await db.execute(sql` CREATE TYPE "public"."enum_users_roles" AS ENUM('admin', 'eic', 'copy-editor', 'editor', 'writer'); CREATE TYPE "public"."article_status_enum" AS ENUM('draft', 'needs-copy', 'needs-1st', 'needs-2nd', 'needs-3rd', 'ready'); @@ -310,7 +310,7 @@ export async function up({ db }: MigrateUpArgs): Promise { CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`) } -export async function down({ db }: MigrateDownArgs): Promise { +export async function down({ db, payload, req }: MigrateDownArgs): Promise { await db.execute(sql` DROP TABLE "users_roles" CASCADE; DROP TABLE "users_positions" CASCADE; diff --git a/migrations/20260213_223303_remove_copy_workflow.ts b/migrations/20260213_223303_remove_copy_workflow.ts index 6b8b50f..ec1996b 100644 --- a/migrations/20260213_223303_remove_copy_workflow.ts +++ b/migrations/20260213_223303_remove_copy_workflow.ts @@ -1,6 +1,6 @@ import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' -export async function up({ db }: MigrateUpArgs): Promise { +export async function up({ db, payload, req }: MigrateUpArgs): Promise { await db.execute(sql` ALTER TABLE "articles" DROP CONSTRAINT "articles_copy_editor1_id_users_id_fk"; @@ -37,7 +37,7 @@ export async function up({ db }: MigrateUpArgs): Promise { DROP TYPE "public"."article_status_enum";`) } -export async function down({ db }: MigrateDownArgs): Promise { +export async function down({ db, payload, req }: MigrateDownArgs): Promise { await db.execute(sql` CREATE TYPE "public"."article_status_enum" AS ENUM('draft', 'needs-copy', 'needs-1st', 'needs-2nd', 'needs-3rd', 'ready'); ALTER TYPE "public"."enum_users_roles" ADD VALUE 'copy-editor' BEFORE 'editor'; diff --git a/migrations/20260217_015404_add_photographer_to_media.ts b/migrations/20260217_015404_add_photographer_to_media.ts index d7fe06a..0d92fa1 100644 --- a/migrations/20260217_015404_add_photographer_to_media.ts +++ b/migrations/20260217_015404_add_photographer_to_media.ts @@ -1,13 +1,13 @@ import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' -export async function up({ db }: MigrateUpArgs): Promise { +export async function up({ db, payload, req }: MigrateUpArgs): Promise { await db.execute(sql` ALTER TABLE "media" ADD COLUMN "photographer_id" integer; ALTER TABLE "media" ADD CONSTRAINT "media_photographer_id_users_id_fk" FOREIGN KEY ("photographer_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; CREATE INDEX "media_photographer_idx" ON "media" USING btree ("photographer_id");`) } -export async function down({ db }: MigrateDownArgs): Promise { +export async function down({ db, payload, req }: MigrateDownArgs): Promise { await db.execute(sql` ALTER TABLE "media" DROP CONSTRAINT "media_photographer_id_users_id_fk"; diff --git a/migrations/20260310_211734_add_user_slug.ts b/migrations/20260310_211734_add_user_slug.ts index 5568d7e..598a452 100644 --- a/migrations/20260310_211734_add_user_slug.ts +++ b/migrations/20260310_211734_add_user_slug.ts @@ -1,6 +1,6 @@ import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' -export async function up({ db }: MigrateUpArgs): Promise { +export async function up({ db, payload, req }: MigrateUpArgs): Promise { await db.execute(sql` ALTER TABLE "users" ADD COLUMN "slug" varchar; @@ -21,7 +21,7 @@ export async function up({ db }: MigrateUpArgs): Promise { CREATE UNIQUE INDEX "users_slug_idx" ON "users" USING btree ("slug");`) } -export async function down({ db }: MigrateDownArgs): Promise { +export async function down({ db, payload, req }: MigrateDownArgs): Promise { await db.execute(sql` DROP INDEX "users_slug_idx"; ALTER TABLE "users" DROP COLUMN "slug";`) diff --git a/migrations/index.ts b/migrations/index.ts index e587f2e..17cf637 100644 --- a/migrations/index.ts +++ b/migrations/index.ts @@ -45,7 +45,7 @@ export const migrations = [ { up: migration_20260316_145613_remove_editorial_section.up, down: migration_20260316_145613_remove_editorial_section.down, - name: '20260316_145613_remove_editorial_section' + name: '20260316_145613_remove_editorial_section', }, { up: migration_20260317_200000_add_opinion_type_and_caption.up, diff --git a/payload-types.ts b/payload-types.ts index 8c207fa..c0c5dd0 100644 --- a/payload-types.ts +++ b/payload-types.ts @@ -143,6 +143,10 @@ export interface User { */ section?: ('news' | 'features' | 'opinion' | 'sports') | 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; @@ -564,6 +568,7 @@ export interface UsersSelect { roles?: T; section?: T; headshot?: T; + oneLiner?: T; bio?: T; positions?: | 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, ''); +}