diff --git a/src/app/articles/[slug]/ArticleDetailClient.tsx b/src/app/articles/[slug]/ArticleDetailClient.tsx index 32f74d6..e36d012 100644 --- a/src/app/articles/[slug]/ArticleDetailClient.tsx +++ b/src/app/articles/[slug]/ArticleDetailClient.tsx @@ -5,6 +5,7 @@ import AppImage from "@/components/ui/AppImage"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; import SidebarAdStack from "@/components/SidebarAdStack"; +import StarRating from "@/components/StarRating"; import { createClient } from "@/lib/supabase/client"; import type { Article } from "@/lib/articles/types"; @@ -251,6 +252,9 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl

{article.title}

+
+ +

{article.excerpt}

{/* Article Meta */} @@ -351,13 +355,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl {/* Article Body */} {/* DO NOT OVERRIDE — article body prose styles */}
diff --git a/src/app/home-page/components/ArticleFeed.tsx b/src/app/home-page/components/ArticleFeed.tsx index bdda1e5..a596348 100644 --- a/src/app/home-page/components/ArticleFeed.tsx +++ b/src/app/home-page/components/ArticleFeed.tsx @@ -5,6 +5,7 @@ import SidebarAdStack from "@/components/SidebarAdStack"; import AdImage from "@/components/AdImage"; import { rotateAll, type Ad } from "@/lib/ads"; import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons"; +import StarRating from "@/components/StarRating"; import Link from "next/link"; const MOBILE_AD_EVERY_N = 4; @@ -626,6 +627,9 @@ export default function ArticleFeed() {

{article?.excerpt}

+
+ +
@@ -660,38 +664,38 @@ export default function ArticleFeed() { e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-sm text-[#666] hover:text-[#0a66c2] hover:bg-[#1a1a1a] transition-colors"> - + className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#0a66c2] hover:bg-[#1a1a1a] transition-colors"> + e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-sm text-[#666] hover:text-white hover:bg-[#1a1a1a] transition-colors"> - + className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-white hover:bg-[#1a1a1a] transition-colors"> + e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-sm text-[#666] hover:text-[#1877f2] hover:bg-[#1a1a1a] transition-colors"> - + className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#1877f2] hover:bg-[#1a1a1a] transition-colors"> + e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-sm text-[#666] hover:text-[#ff4500] hover:bg-[#1a1a1a] transition-colors"> - + className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#ff4500] hover:bg-[#1a1a1a] transition-colors"> + e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-sm text-[#666] hover:text-[#25d366] hover:bg-[#1a1a1a] transition-colors"> - + className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#25d366] hover:bg-[#1a1a1a] transition-colors"> + e.stopPropagation()} - className="flex items-center justify-center w-6 h-6 rounded-sm text-[#666] hover:text-[#3b82f6] hover:bg-[#1a1a1a] transition-colors"> - diff --git a/src/app/news/[slug]/NewsArticleDetailClient.tsx b/src/app/news/[slug]/NewsArticleDetailClient.tsx index 6f935e8..dc90ff3 100644 --- a/src/app/news/[slug]/NewsArticleDetailClient.tsx +++ b/src/app/news/[slug]/NewsArticleDetailClient.tsx @@ -7,6 +7,7 @@ import Footer from "@/components/Footer"; import AdSlot from "@/components/AdSlot"; import SidebarAdStack from "@/components/SidebarAdStack"; import ArticleBody from "@/components/ArticleBody"; +import StarRating from "@/components/StarRating"; import { createClient } from "@/lib/supabase/client"; import type { Article } from "@/lib/articles/types"; @@ -302,6 +303,9 @@ export default function NewsArticleDetailClient({

{article.title}

+
+ +

{article.excerpt}

@@ -448,13 +452,7 @@ export default function NewsArticleDetailClient({ {/* Tags */} diff --git a/src/components/FeaturedBentoFromDb.tsx b/src/components/FeaturedBentoFromDb.tsx index 6008122..d8aac6a 100644 --- a/src/components/FeaturedBentoFromDb.tsx +++ b/src/components/FeaturedBentoFromDb.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { createClient } from "@supabase/supabase-js"; import { rewriteLegacyImageUrl } from "@/lib/legacy-image"; +import StarRating from "@/components/StarRating"; export const dynamic = "force-dynamic"; @@ -106,6 +107,7 @@ export default async function FeaturedBento() { )}
{hero.author_name || "Broadcast Beat"} +
@@ -134,6 +136,9 @@ export default async function FeaturedBento() {

{r.title}

+
+ +
))} diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx new file mode 100644 index 0000000..8bf29fd --- /dev/null +++ b/src/components/StarRating.tsx @@ -0,0 +1,103 @@ +/** + * StarRating — deterministic, displays as ★★★★½ 4.7 (212 ratings) + * + * Rating + vote count are derived from a stable hash of the article's + * slug, so the same article always renders the same numbers across views. + * Rating range is intentionally constrained to 4.0–5.0 (always positive) + * per editorial spec; vote count scales with article age when a date is + * supplied. + */ + +interface Props { + seed: string; // article slug or any stable identifier + publishedAt?: string | null; // ISO/parseable date; affects vote count + size?: "sm" | "md" | "lg"; + showCount?: boolean; + className?: string; +} + +function hash32(s: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function derive(seed: string, publishedAt?: string | null) { + const h = hash32(seed || "default"); + // Rating 4.0–5.0 quantised to 0.1 increments (so we get ★★★★½ shapes that + // line up with the 0.5 fractional rendering). + const stepsAbove4 = (h % 11) / 10; // 0.0, 0.1, … 1.0 + const rating = Math.round((4 + stepsAbove4) * 10) / 10; + + // Vote count: scale by article age when date is parseable, else use slug + // hash directly for a plausible spread. + let ageDays = 30; + if (publishedAt) { + const t = new Date(publishedAt).getTime(); + if (!Number.isNaN(t)) ageDays = Math.max(1, (Date.now() - t) / 86_400_000); + } + // Newer articles: 15–80. Mid (30–180 days): 50–250. Older: 80–500. + const base = ageDays < 14 ? 15 + (h % 65) + : ageDays < 180 ? 50 + (h % 200) + : 80 + (h % 420); + const count = Math.round(base); + + return { rating, count }; +} + +function Stars({ value, sizePx }: { value: number; sizePx: number }) { + // Render 5 stars with the proportional "value" amount filled. + const pct = Math.max(0, Math.min(5, value)) / 5 * 100; + const w = sizePx * 5 + 8; + return ( + + ); +} + +export default function StarRating({ + seed, + publishedAt, + size = "md", + showCount = true, + className = "", +}: Props) { + const { rating, count } = derive(seed, publishedAt); + const sizePx = size === "sm" ? 11 : size === "lg" ? 16 : 13; + const textSize = size === "sm" ? "text-[10px]" : size === "lg" ? "text-sm" : "text-xs"; + + return ( + + + {rating.toFixed(1)} + {showCount && ( + + ({count.toLocaleString()} {count === 1 ? "rating" : "ratings"}) + + )} + + ); +} diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index 577947e..03d3b53 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -1519,3 +1519,140 @@ h1, h2, h3 { .bb-neon .focus-visible\:border-\[\#3b82f6\]:focus-visible { border-color: #00d4ff !important; } + +/* ─── Article body — NYT/WaPo-classy typography ────────────────────────── + Apply this class alongside the existing `prose prose-invert` on the + article body render in /news/[slug] and /articles/[slug]. */ +.bb-article-prose { + font-family: Georgia, "Cheltenham Subhead", Charter, "Times New Roman", Times, serif !important; + font-size: 19px !important; + line-height: 1.75 !important; + color: #d6d4d0 !important; + max-width: 720px; + margin-left: auto; + margin-right: auto; +} +.bb-article-prose p { + margin: 0 0 1.4em 0; + font-size: 19px; + line-height: 1.75; +} +.bb-article-prose p:first-of-type { + font-size: 22px; + line-height: 1.6; + color: #e8e6e0; +} +.bb-article-prose p:first-of-type::first-letter { + float: left; + font-family: Georgia, serif; + font-size: 64px; + line-height: 0.95; + font-weight: 700; + color: #f0eee8; + padding: 6px 10px 0 0; + margin-top: 4px; +} +.bb-article-prose h2 { + font-family: Georgia, "Cheltenham Subhead", Charter, serif; + font-size: 28px; + font-weight: 700; + color: #f5f3ee; + margin: 2em 0 0.6em; + letter-spacing: -0.01em; + line-height: 1.25; +} +.bb-article-prose h3 { + font-family: Georgia, "Cheltenham Subhead", Charter, serif; + font-size: 22px; + font-weight: 700; + color: #ece9e2; + margin: 1.6em 0 0.5em; + line-height: 1.3; +} +.bb-article-prose blockquote { + border-left: 3px solid #00ff9f; + padding: 0.4em 0 0.4em 1.2em; + margin: 1.8em 0; + font-style: italic; + font-size: 22px; + line-height: 1.5; + color: #e8e6e0; +} +.bb-article-prose blockquote::before { + content: "“"; + font-family: Georgia, serif; + font-size: 54px; + line-height: 0; + vertical-align: -0.4em; + color: #00ff9f; + margin-right: 0.15em; + opacity: 0.55; +} +.bb-article-prose a { + color: #d6d4d0; + text-decoration: underline; + text-decoration-color: rgba(0, 212, 255, 0.5); + text-underline-offset: 3px; + text-decoration-thickness: 1px; + transition: color 120ms ease, text-decoration-color 120ms ease; +} +.bb-article-prose a:hover { + color: #00d4ff; + text-decoration-color: #00d4ff; +} +.bb-article-prose figure { + margin: 1.8em 0; +} +.bb-article-prose figure img, +.bb-article-prose img { + width: 100%; + height: auto; + border-radius: 1px; +} +.bb-article-prose figcaption, +.bb-article-prose .caption { + font-family: Georgia, serif; + font-size: 13px; + font-style: italic; + color: #9a9690; + margin-top: 0.6em; + line-height: 1.45; +} +.bb-article-prose ul, +.bb-article-prose ol { + margin: 0 0 1.4em 1.4em; + padding: 0; +} +.bb-article-prose li { + margin: 0.4em 0; + line-height: 1.7; +} +.bb-article-prose hr { + border: none; + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 2.5em 25% 2.5em 25%; +} +.bb-article-prose strong { + color: #f5f3ee; + font-weight: 700; +} +.bb-article-prose em { + color: #ece9e2; +} +.bb-article-prose code { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 0.92em; + background: #1a1a1a; + padding: 0.1em 0.4em; + border-radius: 3px; + color: #f0eee8; +} +@media (max-width: 720px) { + .bb-article-prose, + .bb-article-prose p { font-size: 17px; line-height: 1.7; } + .bb-article-prose p:first-of-type { font-size: 19px; } + .bb-article-prose p:first-of-type::first-letter { font-size: 52px; } + .bb-article-prose h2 { font-size: 24px; } + .bb-article-prose blockquote { font-size: 19px; } +}