Skip to content

Commit

Permalink
feat(epic): marketing blog page (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
james-a-morris authored Oct 3, 2024
1 parent 68b15d1 commit f6f1485
Show file tree
Hide file tree
Showing 38 changed files with 1,779 additions and 16 deletions.
5 changes: 5 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const nextConfig = {
hostname: "pbs.twimg.com",
pathname: "**",
},
{
protocol: "https",
hostname: "images.ctfassets.net",
pathname: "**",
}
],
},
};
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,31 @@
},
"dependencies": {
"@amplitude/analytics-browser": "^2.4.1",
"@contentful/rich-text-plain-text-renderer": "^16.2.8",
"@contentful/rich-text-react-renderer": "^15.22.9",
"@contentful/rich-text-types": "^16.8.3",
"@headlessui/react": "^1.7.18",
"@next/third-parties": "^14.2.5",
"@typeform/embed-react": "^3.17.0",
"contentful": "^10.13.1",
"embla-carousel-auto-scroll": "^8.1.5",
"embla-carousel-autoplay": "^8.1.5",
"embla-carousel-react": "^8.1.5",
"lodash.words": "^4.2.0",
"luxon": "^3.5.0",
"next": "14.1.0",
"numeral": "^2.0.6",
"react": "^18",
"react-dom": "^18",
"sharp": "^0.33.5",
"tailwind-merge": "^2.2.1",
"tailwind-scrollbar-hide": "^1.1.7",
"use-debounce": "^10.0.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/lodash.words": "^4.2.9",
"@types/luxon": "^3.4.2",
"@types/node": "^20",
"@types/numeral": "^2.0.5",
"@types/react": "^18",
Expand Down
147 changes: 147 additions & 0 deletions src/app/(routes)/blog/[slug]/_components/article-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
documentToReactComponents,
RenderMark,
RenderNode,
} from "@contentful/rich-text-react-renderer";
import { BLOCKS, Document, INLINES, MARKS } from "@contentful/rich-text-types";
import Link from "next/link";
import { isExternal } from "util/types";
import Divider from "./divider";
import { IframeContainer } from "./iframe-container";
import { Text } from "@/app/_components/text";
import { Asset } from "contentful";
import ContentfulImage from "./contentful-image";

// Map text-format types to custom components

const markRenderers: RenderMark = {
[MARKS.BOLD]: (text) => <strong>{text}</strong>,
[MARKS.ITALIC]: (text) => <em>{text}</em>,
[MARKS.UNDERLINE]: (text) => <span className="underline">{text}</span>,
[MARKS.CODE]: (text) => <code>{text}</code>,
[MARKS.SUPERSCRIPT]: (text) => <sup>{text}</sup>,
[MARKS.SUBSCRIPT]: (text) => <sub>{text}</sub>,
};

const nodeRenderers: RenderNode = {
[INLINES.HYPERLINK]: (node, children) => {
const href = node.data.uri as string;
if (
href.includes("youtube.com/embed") ||
href.includes("player.vimeo.com") ||
children?.toString().toLowerCase().includes("iframe") // to handle uncommon cases, creator can set the text to "iframe"
) {
return (
<IframeContainer>
<iframe
width="100%"
height="100%"
src={href}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
style={{
position: "absolute",
top: 0,
left: 0,
clipPath: "inset(0% 0% 0% 0% round 16px)",
}}
></iframe>
</IframeContainer>
);
}
return (
<Link
target={isExternal(href) ? "_blank" : undefined}
className="hover:text-text underline"
href={href}
type="external"
>
{children}
</Link>
);
},
[BLOCKS.DOCUMENT]: (_, children) => children,
[BLOCKS.PARAGRAPH]: (_, children) => (
<Text variant="body">
<p>{children}</p>
</Text>
),
[BLOCKS.HEADING_1]: (_, children) => (
<Text variant="heading-1" className="py-4">
<h1>{children}</h1>
</Text>
),
[BLOCKS.HEADING_2]: (_, children) => (
<Text variant="heading-3" className="py-4">
<h2>{children}</h2>
</Text>
),
[BLOCKS.HEADING_3]: (_, children) => (
<Text variant="heading-4">
<h3>{children}</h3>
</Text>
),
[BLOCKS.HEADING_4]: (_, children) => (
<Text variant="body">
<h4>{children}</h4>
</Text>
),
[BLOCKS.HEADING_5]: (_, children) => (
<Text variant="body">
<h5>{children}</h5>
</Text>
),
[BLOCKS.HEADING_6]: (_, children) => (
<Text variant="body">
<h6>{children}</h6>
</Text>
),
[BLOCKS.EMBEDDED_RESOURCE]: (_, children) => <div>{children}</div>,
[BLOCKS.UL_LIST]: (_, children) => <ul className="list-disc pl-8">{children}</ul>,
[BLOCKS.OL_LIST]: (_, children) => <ol className="list-decimal pl-8">{children}</ol>,
[BLOCKS.LIST_ITEM]: (_, children) => <li>{children}</li>,
[BLOCKS.QUOTE]: (_, children) => <blockquote>{children}</blockquote>,
[BLOCKS.HR]: () => <Divider />,
[BLOCKS.TABLE]: (_, children) => (
<table>
<tbody>{children}</tbody>
</table>
),
[BLOCKS.TABLE_ROW]: (_, children) => <tr>{children}</tr>,
[BLOCKS.TABLE_HEADER_CELL]: (_, children) => <th>{children}</th>,
[BLOCKS.TABLE_CELL]: (_, children) => <td>{children}</td>,
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const data = node.data.target as Asset<"WITHOUT_UNRESOLVABLE_LINKS", string>;
const { file, description, title } = data.fields;
const mimeGroup = file?.contentType.split("/")[0]; // image / video etc
switch (mimeGroup) {
case "image":
return <ContentfulImage image={data} />;
// TODO: test this, make custom component if necessary
case "video":
return (
<video title={title} aria-description={description} src={`https:${file?.url}`}>
{description}
</video>
);
// TODO: add other asset types, handle them
default:
return <p>unknown file type</p>;
}
},
};

const options = {
renderNode: nodeRenderers,
renderMark: markRenderers,
preserveWhitespace: true,
};

export default function ArticleContent({ content }: { content: Document }) {
return (
<article className="flex flex-col gap-4">
{documentToReactComponents(content, options)}
</article>
);
}
16 changes: 16 additions & 0 deletions src/app/(routes)/blog/[slug]/_components/breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Link from "next/link";
import { ChevronDownIcon } from "@/app/_components/icons";

export default function Breadcrumb({ fullTitle }: { fullTitle: string }) {
// Max title to 40 characters
const title = fullTitle.length > 40 ? fullTitle.slice(0, 40) + "..." : fullTitle;
return (
<div className="flex items-center gap-2">
<Link href="/blog" className="text-sm font-lighter leading-tight ">
Blog
</Link>
<ChevronDownIcon className="-rotate-90" />
<div className="text-sm font-lighter leading-tight text-aqua-100">{title}</div>
</div>
);
}
49 changes: 49 additions & 0 deletions src/app/(routes)/blog/[slug]/_components/contentful-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Asset } from "contentful";
import Image from "next/image";
import { object } from "zod";

export default function ContentfulImage({
image,
borderless,
displayDescription,
fillDisplay,
}: {
image?: Asset<"WITHOUT_UNRESOLVABLE_LINKS", string>;
borderless?: boolean;
displayDescription?: boolean;
fillDisplay?: boolean;
}) {
if (!image) {
return null;
}

const { file, description, title } = image.fields;
const url = file?.url;
if (!url) {
return null;
}
const urlWithProtocol = `https:${url}`;

const classes = borderless ? "" : "rounded-3xl border border-white-translucent";

const props = fillDisplay
? { fill: true, objectFit: "cover" }
: {
height: file.details.image?.height,
width: file.details.image?.width,
};

return (
<div className="relative flex h-full w-full flex-col items-center gap-4">
<Image
src={urlWithProtocol}
alt={description ?? "description"}
title={title}
className={classes}
aria-description={description}
{...props}
/>
{description && displayDescription && <p>{description}</p>}
</div>
);
}
9 changes: 9 additions & 0 deletions src/app/(routes)/blog/[slug]/_components/divider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { twMerge } from "@/app/_lib/tw-merge";

export default function Divider({ className }: { className?: string }) {
return (
<div
className={twMerge("h-0 w-full border-t border-white-translucent", className)}
></div>
);
}
13 changes: 13 additions & 0 deletions src/app/(routes)/blog/[slug]/_components/iframe-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { twMerge } from "@/app/_lib/tw-merge";

type Props = {
className?: string;
};

export function IframeContainer({ className, children }: React.PropsWithChildren<Props>) {
return (
<span className={twMerge("relative mx-auto block aspect-video w-full ", className)}>
{children}
</span>
);
}
33 changes: 33 additions & 0 deletions src/app/(routes)/blog/[slug]/_components/meta-info.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Text } from "@/app/_components/text";
import { getReadingTime } from "@/app/_lib/contentful";
import { Document } from "@contentful/rich-text-types";
import { DateTime } from "luxon";
import { twMerge } from "tailwind-merge";

export function MetaInfo({
isoCreatedDate,
content,
preventCenter,
compact,
}: {
isoCreatedDate: string;
content: Document;
preventCenter?: boolean;
compact?: boolean;
}) {
const dateString = DateTime.fromISO(isoCreatedDate).toFormat("MMM dd, yyyy");
const minutesToRead = getReadingTime(content);
return (
<div
className={twMerge(
"flex items-center justify-center gap-3 text-grey-400 sm:justify-start",
preventCenter ? ["justify-start"] : ["justify-center", "sm:justify-start"],
)}
>
<Text variant={compact ? "cap-case-xs" : "cap-case-sm"}>{dateString}</Text>
<Text variant={compact ? "cap-case-xs" : "cap-case-sm"}>
{minutesToRead} min read
</Text>
</div>
);
}
Loading

0 comments on commit f6f1485

Please sign in to comment.