diff --git a/src/api/ghost.ts b/src/api/ghost.ts index 23f758b..724aa03 100644 --- a/src/api/ghost.ts +++ b/src/api/ghost.ts @@ -54,6 +54,7 @@ interface GhostResponse { posts?: T[] pages?: T[] tags?: T[] + authors?: T[] meta: { pagination: { page: number @@ -113,3 +114,10 @@ export async function getTags() { const data = await ghostFetch>("tags/?limit=all") return data.tags ?? [] } + +export async function getAuthor(slug: string) { + const data = await ghostFetch>( + `authors/slug/${slug}/` + ) + return data.authors?.[0] ?? null +} diff --git a/src/components/author-bio.test.tsx b/src/components/author-bio.test.tsx new file mode 100644 index 0000000..0615b55 --- /dev/null +++ b/src/components/author-bio.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { AuthorBio } from "./author-bio" + +const useAuthorMock = vi.hoisted(() => vi.fn()) + +vi.mock("@/lib/use-ghost", () => ({ + useAuthor: useAuthorMock, +})) + +describe("AuthorBio", () => { + it("renders the fallback name, bio, and placeholder when Ghost has no data", () => { + useAuthorMock.mockReturnValue({ data: undefined }) + + render() + + expect(screen.getByText("Will Thomson")).toBeInTheDocument() + expect(screen.getByText(/certified arborist/i)).toBeInTheDocument() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + }) + + it("renders the Ghost profile image, name, and bio when present", () => { + useAuthorMock.mockReturnValue({ + data: { + id: "1", + name: "Will Thomson", + slug: "will", + profile_image: "https://content.example.com/will.jpg", + bio: "Arborist and author.", + }, + }) + + render() + + const img = screen.getByRole("img", { name: "Will Thomson" }) + expect(img).toHaveAttribute("src", "https://content.example.com/will.jpg") + expect(screen.getByText("Arborist and author.")).toBeInTheDocument() + expect(screen.queryByText(/certified arborist/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/components/author-bio.tsx b/src/components/author-bio.tsx index e96ebcc..3478adc 100644 --- a/src/components/author-bio.tsx +++ b/src/components/author-bio.tsx @@ -1,14 +1,38 @@ +import { useAuthor } from "@/lib/use-ghost" + +// Single-author site: the Ghost staff profile is the source of truth for the +// photo and bio, editable in Ghost Admin without a deploy. The hardcoded +// strings below are fallbacks for while the Ghost profile fields are empty +// (and for when the Content API is unreachable). +const AUTHOR_SLUG = "will" + +const FALLBACK_NAME = "Will Thomson" +const FALLBACK_BIO = + "Will is a certified arborist and landscape designer. He holds an MSc in " + + "Environmental Science from the American University of Beirut and an MLA " + + "from the Spitzer School of Architecture, and is a certified hater of " + + "using trees to score political points." + export function AuthorBio() { + const { data: author } = useAuthor(AUTHOR_SLUG) + return (
-
+ {author?.profile_image ? ( + {author.name} + ) : ( +
+ )}
-

Will Thomson

+

+ {author?.name ?? FALLBACK_NAME} +

- Will is a certified arborist and landscape designer. He holds an MSc - in Environmental Science from the American University of Beirut and an - MLA from the Spitzer School of Architecture, and is a certified hater - of using trees to score political points. + {author?.bio ?? FALLBACK_BIO}

diff --git a/src/lib/use-ghost.ts b/src/lib/use-ghost.ts index 1bda8c5..a6a4ea8 100644 --- a/src/lib/use-ghost.ts +++ b/src/lib/use-ghost.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query" -import { getPost, getPosts, getPage, getTags } from "@/api/ghost" +import { getAuthor, getPost, getPosts, getPage, getTags } from "@/api/ghost" export function usePosts(options?: { page?: number @@ -36,3 +36,11 @@ export function useTags() { queryFn: getTags, }) } + +export function useAuthor(slug: string) { + return useQuery({ + queryKey: ["ghost", "author", slug], + queryFn: () => getAuthor(slug), + enabled: !!slug, + }) +}