Fazekit

Code

"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;

More in Blog & Docs

View all →