Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(epic): marketing blog page #59

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
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