Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,43 @@ html {
animation: fadeInUp 0.5s ease-out forwards;
}

/* ─── Loading placeholders ─── */

/* A light band sweeping left→right, in the spirit of the reveal animations
below — quieter than a pulsing opacity blink, and it reads as "typesetting
in progress" rather than "widget buffering". */
@keyframes skeletonSweep {
from { background-position: 150% 0; }
to { background-position: -150% 0; }
}

/* Placeholder for a line of body text — light grey from the charcoal ramp,
one step lighter than --color-border-light so placeholders stay quieter
than the rules and frames around them. */
.skeleton-bar {
background-color: var(--color-charcoal-200);
background-image: linear-gradient(
90deg,
transparent 20%,
var(--color-charcoal-100) 50%,
transparent 80%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: skeletonSweep 2s ease-in-out infinite;
}

/* Heavier weight, for headings and other display type. */
.skeleton-bar-strong {
background-color: var(--color-charcoal-300);
}

/* Staggers the sweep down a stack so it travels through the block instead of
every line flashing in unison. */
.skeleton-delay-1 { animation-delay: 0.15s; }
.skeleton-delay-2 { animation-delay: 0.3s; }
.skeleton-delay-3 { animation-delay: 0.45s; }

/* Draw stroke forward (dashoffset 1 → 0) */
@keyframes drawIn {
from { stroke-dashoffset: 1; }
Expand Down
153 changes: 153 additions & 0 deletions src/app/memos/[slug]/MemoSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// The placeholder a memo route shows while its content streams in.
//
// It mirrors the real layout in page.tsx (hero → signpost → key messages →
// body) at the same widths and spacing, so swapping in the real memo doesn't
// shift anything on screen.
//
// Chrome that's identical on every memo — the Key Messages frame and its
// eyebrow, the numbered indices, the Signpost's rail and Share block — is
// rendered for real rather than faked as grey blocks. Only the parts that
// differ per memo are placeholders, which keeps the page recognisably a memo
// while it loads instead of a generic loading card.

const KEY_MESSAGES_FILL = {
// The fills the real Key Messages boxes use (see page.tsx). Hardcoded there
// too — they predate the linen ramp.
default: "bg-[#f0e5dc]",
toronto: "bg-[#d7e4f3]",
} as const;

const DELAYS = ["", "skeleton-delay-1", "skeleton-delay-2", "skeleton-delay-3"];

// One placeholder line. `i` staggers the sweep down a stack.
function Line({
className,
strong = false,
i = 0,
}: {
className: string;
strong?: boolean;
i?: number;
}) {
return (
<div
className={`skeleton-bar ${strong ? "skeleton-bar-strong" : ""} ${
DELAYS[i % DELAYS.length]
} ${className}`}
/>
);
}

export function MemoSkeleton({
brand = "default",
showBackLink = false,
}: {
brand?: keyof typeof KEY_MESSAGES_FILL;
showBackLink?: boolean;
}) {
return (
<div
className="mx-[10px] my-[10px] border border-border-light bg-bg"
role="status"
aria-busy="true"
aria-live="polite"
>
<span className="sr-only">Loading memo…</span>

<div aria-hidden="true">
{/* Hero — mirrors MemoHero: title, then author frame beside name/date.
Title lines are display-weight; the author frame uses the same
border-light square MemoHero shows before its photo loads. */}
<div className="max-w-[1400px] mx-auto w-full px-[5vw] py-10 md:px-[10vw]">
{showBackLink && <Line className="h-3 w-40 mb-6" />}

<div className="max-w-[720px] mb-4 space-y-3">
<Line className="h-8 md:h-10 w-full" strong />
<Line className="h-8 md:h-10 w-3/4" strong i={1} />
</div>

<div className="flex items-center gap-5 mb-6">
<div className="w-32 h-32 bg-border-light shrink-0" />
<div className="space-y-2.5">
<Line className="h-5 w-44" />
<Line className="h-3 w-32" i={1} />
</div>
</div>
</div>

<div className="max-w-[1400px] mx-auto px-[5vw] md:px-[10vw] pt-4 pb-[52px] 2xl-memo:grid 2xl-memo:grid-cols-[240px_minmax(0,1fr)] 2xl-memo:gap-12">
{/* Signpost — the desktop-only rail. Its accent top rule, Share
eyebrow, share buttons and dotted track are all real. */}
<div className="print-hide hidden 2xl-memo:block">
<div className="sticky top-[90px] border-accent border-t-[2px]">
<div className="pt-4 pb-4 mb-4 border-b border-border-light">
<span className="type-label text-text-secondary block mb-3">
Share
</span>
<div className="flex gap-2">
{[0, 1, 2].map((i) => (
<div key={i} className="w-12 h-12 bg-dark" />
))}
</div>
</div>

<div className="relative">
{/* The rail's 2px track, matching Track's geometry. */}
<div className="absolute bottom-2 top-0 left-[11px] w-[2px] bg-border-light" />
<div className="space-y-4">
{["w-full", "w-4/5", "w-11/12", "w-3/4", "w-5/6"].map(
(w, i) => (
<div key={i} className="flex items-start">
<div className="flex w-[24px] shrink-0 justify-center">
<div className="w-[11px] h-[11px] mt-1.5 rounded-full border-[2px] border-border-light bg-bg" />
</div>
<Line className={`h-3.5 mt-1.5 ${w}`} i={i} />
</div>
),
)}
</div>
</div>
</div>
</div>

<div className="max-w-[720px]">
{/* Key Messages — real frame, real eyebrow, real numerals. */}
<div
className={`mb-8 p-6 border-[3px] border-double border-border-light space-y-4 ${KEY_MESSAGES_FILL[brand]}`}
>
<span className="type-label block mb-3">Key Messages</span>
{["w-full", "w-11/12", "w-4/5"].map((w, i) => (
<div key={i} className="flex items-start gap-4">
<span className="type-label mt-2 shrink-0 text-text-secondary">
{String(i + 1).padStart(2, "0")}
</span>
<div className="flex-1 space-y-2.5">
<Line className="h-4 w-full" i={i} />
<Line className={`h-4 ${w}`} i={i + 1} />
</div>
</div>
))}
</div>

{/* Body — paragraphs broken by section headings, on the same
~1.4 line-height rhythm as type-body prose. */}
<div className="space-y-10">
{[0, 1, 2].map((section) => (
<div key={section} className="space-y-3.5">
{section > 0 && (
<Line className="h-6 w-1/2 mb-6" strong />
)}
{["w-full", "w-full", "w-11/12", "w-full", "w-2/3"].map(
(w, i) => (
<Line key={i} className={`h-4 ${w}`} i={i} />
),
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
9 changes: 9 additions & 0 deletions src/app/memos/[slug]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { MemoSkeleton } from "./MemoSkeleton";

// This route reads cookies (draft preview + viewer state), so it renders
// dynamically on every request and its prefetch payload is empty. Without a
// loading boundary a click leaves the previous page on screen for the whole
// round trip, which reads as a dead click.
export default function Loading() {
return <MemoSkeleton />;
}
15 changes: 10 additions & 5 deletions src/app/memos/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,16 @@ export default async function MemoDetailPage({
// engagement UI's signed-in / postal-code-ready states.
const viewer = await getCurrentUser();

let memo;
try {
memo = await fetchMemo(slug);
} catch {
// The memo and the list backing "related memos" don't depend on each other,
// so they go out together rather than one after the other. Both must start
// after primeAdminPreviewToken, which seeds the token apiFetch reads.
// Related memos are decorative — if that list fails the memo still renders.
const [memo, allMemos] = await Promise.all([
fetchMemo(slug).catch(() => null),
fetchMemos().catch(() => []),
]);

if (!memo) {
notFound();
}

Expand Down Expand Up @@ -142,7 +148,6 @@ export default async function MemoDetailPage({
generateBreadcrumbSchema(`/memos/${memo.slug}`, memo.title, configData.siteUrl)
);

const allMemos = await fetchMemos();
const sameCategory = allMemos.filter(
(m) => m.slug !== memo.slug && memo.category && m.category === memo.category,
);
Expand Down
5 changes: 5 additions & 0 deletions src/app/toronto/memos/[slug]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { MemoSkeleton } from "@/app/memos/[slug]/MemoSkeleton";

export default function Loading() {
return <MemoSkeleton brand="toronto" showBackLink />;
}
15 changes: 10 additions & 5 deletions src/app/toronto/memos/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,16 @@ export default async function TorontoMemoDetailPage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
let memo;
try {
memo = await fetchMemo(slug, { publication: PUBLICATION });
} catch {

// The memo and the list backing "related memos" don't depend on each other,
// so they go out together rather than one after the other. Related memos are
// decorative — if that list fails the memo still renders.
const [memo, allMemos] = await Promise.all([
fetchMemo(slug, { publication: PUBLICATION }).catch(() => null),
fetchMemos({ publication: PUBLICATION }).catch(() => []),
]);

if (!memo) {
notFound();
}

Expand Down Expand Up @@ -129,7 +135,6 @@ export default async function TorontoMemoDetailPage({
)
);

const allMemos = await fetchMemos({ publication: PUBLICATION });
const sameCategory = allMemos.filter(
(m) => m.slug !== memo.slug && memo.category && m.category === memo.category,
);
Expand Down
42 changes: 25 additions & 17 deletions src/lib/api/memos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,29 +69,37 @@ export async function fetchMemos(params?: {

type MemoRow = YFMemo & { key_messages?: unknown[] };

const all: MemoRow[] = [];
let page = 1;
const fetchPage = (page: number) =>
apiFetch<YFPaginatedResponse<MemoRow>>("/memos", {
params: { ...queryParams, page: String(page) },
revalidate: 60,
});

while (true) {
queryParams.page = String(page);
const res = await apiFetch<YFPaginatedResponse<MemoRow>>(
"/memos",
{ params: queryParams, revalidate: 60 }
);
all.push(...res.data);
if (page >= res.pagination.pages) break;
page++;
}
// Author titles don't depend on the memo pages, so this goes out alongside
// them rather than waiting for pagination to finish. Failing to resolve
// titles isn't fatal — memos still render without them.
const teamPromise = apiFetch<YFListResponse<YFTeamMember>>("/team", {
revalidate: 3600,
}).catch(() => null);

// Page 1 tells us how many pages there are; the rest are then fetched
// concurrently instead of one round trip at a time.
const first = await fetchPage(1);
const rest = await Promise.all(
Array.from({ length: Math.max(0, first.pagination.pages - 1) }, (_, i) =>
fetchPage(i + 2),
),
);

const all: MemoRow[] = [first, ...rest].flatMap((res) => res.data);

const authorTitles = new Map<string, string | null>();
try {
const team = await apiFetch<YFListResponse<YFTeamMember>>("/team", {
revalidate: 3600,
});
const team = await teamPromise;
if (team) {
for (const t of team.data) {
authorTitles.set(t.slug, t.title ?? null);
}
} catch {}
}

return all.map((m) => mapMemo(m, authorTitles));
}
Expand Down
Loading