"use client";
import * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { journalContent, type Article, type Author, type JournalContent } from "./data";
import { Masthead } from "./sections/masthead";
import { Hero } from "./sections/hero";
import { Latest, Ticker } from "./sections/latest";
import { LongReads, Writers } from "./sections/features";
import { Footer, Newsletter } from "./sections/closing";
import { ArticleView } from "./sections/article-view";
import { scrollToId } from "./sections/ui";
export type { JournalContent, Article, Author };
export interface BlogMagazineTemplateProps {
/** Override any top-level content (brand, articles, authors, newsletter…). Missing keys fall back to the demo copy. */
content?: Partial<JournalContent>;
/** Open this article on first render instead of the front page. */
initialArticleId?: string;
/** Called with the email address after a successful newsletter sign-up. */
onSubscribe?: (email: string) => void;
className?: string;
}
type View = { kind: "home" } | { kind: "article"; id: string };
/**
* The Northwind Journal — an editorial magazine template.
* Front page: utility strip, sticky masthead with animated category underline, cover story, ticker,
* filterable latest grid with trending sidebar, long reads, writers, newsletter and footer.
* Clicking any story opens an in-template reading view with progress bar, scroll-spy TOC and related posts.
*/
export function BlogMagazineTemplate({ content, initialArticleId, onSubscribe, className }: BlogMagazineTemplateProps) {
const c: JournalContent = { ...journalContent, ...content };
const [view, setView] = React.useState<View>(initialArticleId ? { kind: "article", id: initialArticleId } : { kind: "home" });
const [category, setCategory] = React.useState("All");
const homeScroll = React.useRef(0);
const pending = React.useRef<{ id?: string; top?: number } | null>(null);
const byId = React.useMemo(() => new Map(c.articles.map((a) => [a.id, a])), [c.articles]);
const authorOf = React.useCallback(
(id: string): Author => c.authors.find((a) => a.id === id) ?? { id, name: "Staff writer", role: "Contributor", bio: "", hue: 0 },
[c.authors],
);
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((a): a is Article => !!a);
// After a view switch, apply the requested scroll position (top of article, restored front page, or an anchor).
React.useLayoutEffect(() => {
const p = pending.current;
if (!p) return;
pending.current = null;
if (p.id) {
requestAnimationFrame(() => scrollToId(p.id!));
} else {
window.scrollTo({ top: p.top ?? 0, behavior: "auto" });
}
}, [view]);
const openArticle = (id: string) => {
if (view.kind === "home") homeScroll.current = window.scrollY;
pending.current = { top: 0 };
setView({ kind: "article", id });
};
const goHome = (anchor?: string) => {
if (view.kind === "home") {
if (anchor) scrollToId(anchor);
else window.scrollTo({ top: 0, behavior: "smooth" });
return;
}
pending.current = anchor ? { id: anchor } : { top: homeScroll.current };
setView({ kind: "home" });
};
const chooseCategory = (cat: string) => {
setCategory(cat);
goHome("latest");
};
const featured = byId.get(c.featuredId) ?? c.articles[0];
const current = view.kind === "article" ? byId.get(view.id) : undefined;
const related = current
? [
...c.articles.filter((a) => a.id !== current.id && a.category === current.category),
...c.articles.filter((a) => a.id !== current.id && a.category !== current.category),
].slice(0, 3)
: [];
const latest = c.articles.filter((a) => a.id !== featured?.id && !c.secondaryIds.includes(a.id));
return (
<div
className={cn(
"relative w-full bg-background text-foreground antialiased [--nj-accent:#b3261e] dark:[--nj-accent:#ff8a7a]",
className,
)}
>
<a
href="#nj-main"
className="sr-only z-[90] rounded-md bg-foreground px-3 py-2 text-sm text-background focus:not-sr-only focus:fixed focus:left-3 focus:top-3"
>
Skip to content
</a>
<Masthead
brand={c.brand}
dateline={c.dateline}
issue={c.issue}
tagline={c.tagline}
categories={c.categories}
activeCategory={view.kind === "home" ? category : null}
onCategory={chooseCategory}
nav={c.nav}
onNav={(href) => goHome(href.replace(/^#/, ""))}
onHome={() => goHome()}
/>
<main id="nj-main">
{current ? (
<motion.div key={`article-${current.id}`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.35 }}>
<ArticleView
article={current}
author={authorOf(current.authorId)}
related={related}
authorOf={authorOf}
onOpen={openArticle}
onBack={() => goHome()}
onCategory={chooseCategory}
/>
</motion.div>
) : (
<motion.div key="home" initial={false} animate={{ opacity: 1 }} transition={{ duration: 0.35 }}>
{featured && (
<Hero
featured={featured}
secondary={pick(c.secondaryIds)}
picks={pick(c.picks.ids)}
picksTitle={c.picks.title}
authorOf={authorOf}
onOpen={openArticle}
/>
)}
<Ticker items={c.articles.filter((a) => a.id !== featured?.id).slice(0, 8)} onOpen={openArticle} />
<Latest
articles={latest}
trending={pick(c.trendingIds)}
categories={c.categories}
activeCategory={category}
onCategory={chooseCategory}
authorOf={authorOf}
onOpen={openArticle}
/>
<LongReads
eyebrow={c.longReads.eyebrow}
title={c.longReads.title}
subtitle={c.longReads.subtitle}
items={pick(c.longReads.ids)}
authorOf={authorOf}
onOpen={openArticle}
/>
<Writers
eyebrow={c.columnists.eyebrow}
title={c.columnists.title}
authors={c.authors}
countOf={(id) => c.articles.filter((a) => a.authorId === id).length}
/>
</motion.div>
)}
<Newsletter content={c.newsletter} onSubscribe={onSubscribe} />
</main>
<Footer brand={c.brand} content={c.footer} onCategory={chooseCategory} />
</div>
);
}
export default BlogMagazineTemplate;