Skip to content

Commit f6f1485

Browse files
feat(epic): marketing blog page (#59)
1 parent 68b15d1 commit f6f1485

38 files changed

+1779
-16
lines changed

next.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ const nextConfig = {
77
hostname: "pbs.twimg.com",
88
pathname: "**",
99
},
10+
{
11+
protocol: "https",
12+
hostname: "images.ctfassets.net",
13+
pathname: "**",
14+
}
1015
],
1116
},
1217
};

package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,31 @@
1313
},
1414
"dependencies": {
1515
"@amplitude/analytics-browser": "^2.4.1",
16+
"@contentful/rich-text-plain-text-renderer": "^16.2.8",
17+
"@contentful/rich-text-react-renderer": "^15.22.9",
18+
"@contentful/rich-text-types": "^16.8.3",
1619
"@headlessui/react": "^1.7.18",
1720
"@next/third-parties": "^14.2.5",
1821
"@typeform/embed-react": "^3.17.0",
22+
"contentful": "^10.13.1",
1923
"embla-carousel-auto-scroll": "^8.1.5",
2024
"embla-carousel-autoplay": "^8.1.5",
2125
"embla-carousel-react": "^8.1.5",
26+
"lodash.words": "^4.2.0",
27+
"luxon": "^3.5.0",
2228
"next": "14.1.0",
2329
"numeral": "^2.0.6",
2430
"react": "^18",
2531
"react-dom": "^18",
32+
"sharp": "^0.33.5",
2633
"tailwind-merge": "^2.2.1",
34+
"tailwind-scrollbar-hide": "^1.1.7",
35+
"use-debounce": "^10.0.3",
2736
"zod": "^3.22.4"
2837
},
2938
"devDependencies": {
39+
"@types/lodash.words": "^4.2.9",
40+
"@types/luxon": "^3.4.2",
3041
"@types/node": "^20",
3142
"@types/numeral": "^2.0.5",
3243
"@types/react": "^18",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
documentToReactComponents,
3+
RenderMark,
4+
RenderNode,
5+
} from "@contentful/rich-text-react-renderer";
6+
import { BLOCKS, Document, INLINES, MARKS } from "@contentful/rich-text-types";
7+
import Link from "next/link";
8+
import { isExternal } from "util/types";
9+
import Divider from "./divider";
10+
import { IframeContainer } from "./iframe-container";
11+
import { Text } from "@/app/_components/text";
12+
import { Asset } from "contentful";
13+
import ContentfulImage from "./contentful-image";
14+
15+
// Map text-format types to custom components
16+
17+
const markRenderers: RenderMark = {
18+
[MARKS.BOLD]: (text) => <strong>{text}</strong>,
19+
[MARKS.ITALIC]: (text) => <em>{text}</em>,
20+
[MARKS.UNDERLINE]: (text) => <span className="underline">{text}</span>,
21+
[MARKS.CODE]: (text) => <code>{text}</code>,
22+
[MARKS.SUPERSCRIPT]: (text) => <sup>{text}</sup>,
23+
[MARKS.SUBSCRIPT]: (text) => <sub>{text}</sub>,
24+
};
25+
26+
const nodeRenderers: RenderNode = {
27+
[INLINES.HYPERLINK]: (node, children) => {
28+
const href = node.data.uri as string;
29+
if (
30+
href.includes("youtube.com/embed") ||
31+
href.includes("player.vimeo.com") ||
32+
children?.toString().toLowerCase().includes("iframe") // to handle uncommon cases, creator can set the text to "iframe"
33+
) {
34+
return (
35+
<IframeContainer>
36+
<iframe
37+
width="100%"
38+
height="100%"
39+
src={href}
40+
title="YouTube video player"
41+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
42+
allowFullScreen
43+
style={{
44+
position: "absolute",
45+
top: 0,
46+
left: 0,
47+
clipPath: "inset(0% 0% 0% 0% round 16px)",
48+
}}
49+
></iframe>
50+
</IframeContainer>
51+
);
52+
}
53+
return (
54+
<Link
55+
target={isExternal(href) ? "_blank" : undefined}
56+
className="hover:text-text underline"
57+
href={href}
58+
type="external"
59+
>
60+
{children}
61+
</Link>
62+
);
63+
},
64+
[BLOCKS.DOCUMENT]: (_, children) => children,
65+
[BLOCKS.PARAGRAPH]: (_, children) => (
66+
<Text variant="body">
67+
<p>{children}</p>
68+
</Text>
69+
),
70+
[BLOCKS.HEADING_1]: (_, children) => (
71+
<Text variant="heading-1" className="py-4">
72+
<h1>{children}</h1>
73+
</Text>
74+
),
75+
[BLOCKS.HEADING_2]: (_, children) => (
76+
<Text variant="heading-3" className="py-4">
77+
<h2>{children}</h2>
78+
</Text>
79+
),
80+
[BLOCKS.HEADING_3]: (_, children) => (
81+
<Text variant="heading-4">
82+
<h3>{children}</h3>
83+
</Text>
84+
),
85+
[BLOCKS.HEADING_4]: (_, children) => (
86+
<Text variant="body">
87+
<h4>{children}</h4>
88+
</Text>
89+
),
90+
[BLOCKS.HEADING_5]: (_, children) => (
91+
<Text variant="body">
92+
<h5>{children}</h5>
93+
</Text>
94+
),
95+
[BLOCKS.HEADING_6]: (_, children) => (
96+
<Text variant="body">
97+
<h6>{children}</h6>
98+
</Text>
99+
),
100+
[BLOCKS.EMBEDDED_RESOURCE]: (_, children) => <div>{children}</div>,
101+
[BLOCKS.UL_LIST]: (_, children) => <ul className="list-disc pl-8">{children}</ul>,
102+
[BLOCKS.OL_LIST]: (_, children) => <ol className="list-decimal pl-8">{children}</ol>,
103+
[BLOCKS.LIST_ITEM]: (_, children) => <li>{children}</li>,
104+
[BLOCKS.QUOTE]: (_, children) => <blockquote>{children}</blockquote>,
105+
[BLOCKS.HR]: () => <Divider />,
106+
[BLOCKS.TABLE]: (_, children) => (
107+
<table>
108+
<tbody>{children}</tbody>
109+
</table>
110+
),
111+
[BLOCKS.TABLE_ROW]: (_, children) => <tr>{children}</tr>,
112+
[BLOCKS.TABLE_HEADER_CELL]: (_, children) => <th>{children}</th>,
113+
[BLOCKS.TABLE_CELL]: (_, children) => <td>{children}</td>,
114+
[BLOCKS.EMBEDDED_ASSET]: (node) => {
115+
const data = node.data.target as Asset<"WITHOUT_UNRESOLVABLE_LINKS", string>;
116+
const { file, description, title } = data.fields;
117+
const mimeGroup = file?.contentType.split("/")[0]; // image / video etc
118+
switch (mimeGroup) {
119+
case "image":
120+
return <ContentfulImage image={data} />;
121+
// TODO: test this, make custom component if necessary
122+
case "video":
123+
return (
124+
<video title={title} aria-description={description} src={`https:${file?.url}`}>
125+
{description}
126+
</video>
127+
);
128+
// TODO: add other asset types, handle them
129+
default:
130+
return <p>unknown file type</p>;
131+
}
132+
},
133+
};
134+
135+
const options = {
136+
renderNode: nodeRenderers,
137+
renderMark: markRenderers,
138+
preserveWhitespace: true,
139+
};
140+
141+
export default function ArticleContent({ content }: { content: Document }) {
142+
return (
143+
<article className="flex flex-col gap-4">
144+
{documentToReactComponents(content, options)}
145+
</article>
146+
);
147+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Link from "next/link";
2+
import { ChevronDownIcon } from "@/app/_components/icons";
3+
4+
export default function Breadcrumb({ fullTitle }: { fullTitle: string }) {
5+
// Max title to 40 characters
6+
const title = fullTitle.length > 40 ? fullTitle.slice(0, 40) + "..." : fullTitle;
7+
return (
8+
<div className="flex items-center gap-2">
9+
<Link href="/blog" className="text-sm font-lighter leading-tight ">
10+
Blog
11+
</Link>
12+
<ChevronDownIcon className="-rotate-90" />
13+
<div className="text-sm font-lighter leading-tight text-aqua-100">{title}</div>
14+
</div>
15+
);
16+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Asset } from "contentful";
2+
import Image from "next/image";
3+
import { object } from "zod";
4+
5+
export default function ContentfulImage({
6+
image,
7+
borderless,
8+
displayDescription,
9+
fillDisplay,
10+
}: {
11+
image?: Asset<"WITHOUT_UNRESOLVABLE_LINKS", string>;
12+
borderless?: boolean;
13+
displayDescription?: boolean;
14+
fillDisplay?: boolean;
15+
}) {
16+
if (!image) {
17+
return null;
18+
}
19+
20+
const { file, description, title } = image.fields;
21+
const url = file?.url;
22+
if (!url) {
23+
return null;
24+
}
25+
const urlWithProtocol = `https:${url}`;
26+
27+
const classes = borderless ? "" : "rounded-3xl border border-white-translucent";
28+
29+
const props = fillDisplay
30+
? { fill: true, objectFit: "cover" }
31+
: {
32+
height: file.details.image?.height,
33+
width: file.details.image?.width,
34+
};
35+
36+
return (
37+
<div className="relative flex h-full w-full flex-col items-center gap-4">
38+
<Image
39+
src={urlWithProtocol}
40+
alt={description ?? "description"}
41+
title={title}
42+
className={classes}
43+
aria-description={description}
44+
{...props}
45+
/>
46+
{description && displayDescription && <p>{description}</p>}
47+
</div>
48+
);
49+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { twMerge } from "@/app/_lib/tw-merge";
2+
3+
export default function Divider({ className }: { className?: string }) {
4+
return (
5+
<div
6+
className={twMerge("h-0 w-full border-t border-white-translucent", className)}
7+
></div>
8+
);
9+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { twMerge } from "@/app/_lib/tw-merge";
2+
3+
type Props = {
4+
className?: string;
5+
};
6+
7+
export function IframeContainer({ className, children }: React.PropsWithChildren<Props>) {
8+
return (
9+
<span className={twMerge("relative mx-auto block aspect-video w-full ", className)}>
10+
{children}
11+
</span>
12+
);
13+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Text } from "@/app/_components/text";
2+
import { getReadingTime } from "@/app/_lib/contentful";
3+
import { Document } from "@contentful/rich-text-types";
4+
import { DateTime } from "luxon";
5+
import { twMerge } from "tailwind-merge";
6+
7+
export function MetaInfo({
8+
isoCreatedDate,
9+
content,
10+
preventCenter,
11+
compact,
12+
}: {
13+
isoCreatedDate: string;
14+
content: Document;
15+
preventCenter?: boolean;
16+
compact?: boolean;
17+
}) {
18+
const dateString = DateTime.fromISO(isoCreatedDate).toFormat("MMM dd, yyyy");
19+
const minutesToRead = getReadingTime(content);
20+
return (
21+
<div
22+
className={twMerge(
23+
"flex items-center justify-center gap-3 text-grey-400 sm:justify-start",
24+
preventCenter ? ["justify-start"] : ["justify-center", "sm:justify-start"],
25+
)}
26+
>
27+
<Text variant={compact ? "cap-case-xs" : "cap-case-sm"}>{dateString}</Text>
28+
<Text variant={compact ? "cap-case-xs" : "cap-case-sm"}>
29+
{minutesToRead} min read
30+
</Text>
31+
</div>
32+
);
33+
}

0 commit comments

Comments
 (0)