diff --git a/apps/foundation/app/(Site)/blog/[slug]/BlogPost.tsx b/apps/foundation/app/(Site)/blog/[slug]/BlogPost.tsx new file mode 100644 index 00000000..46f43d2c --- /dev/null +++ b/apps/foundation/app/(Site)/blog/[slug]/BlogPost.tsx @@ -0,0 +1,67 @@ +import PortableText from '@/components/PortableText'; +import { getLocale, getTranslations } from 'next-intl/server'; +import { Button } from '@session/ui/ui/button'; +import { cn } from '@session/ui/lib/utils'; +import { getLangDir } from 'rtl-detect'; +import Link from 'next/link'; +import { ButtonDataTestId } from '@/testing/data-test-ids'; +import { SANITY_SCHEMA_URL } from '@/lib/constants'; +import type { FormattedPostType } from '@session/sanity-cms/queries/getPost'; +import { notFound } from 'next/navigation'; +import logger from '@/lib/logger'; +import PostInfoBlock from '@/app/(Site)/blog/[slug]/PostInfoBlock'; +import HeadingOutline from '@/app/(Site)/blog/[slug]/HeadingOutline'; + +export type PostProps = { + post: FormattedPostType; +}; + +export default async function BlogPost({ post }: PostProps) { + const blogDictionary = await getTranslations('blog'); + const locale = await getLocale(); + const direction = getLangDir(locale); + + const body = post.body; + + if (!body) { + logger.error(`No body found for post: ${post.slug}`); + return notFound(); + } + + const allH2s = body.filter((block) => block._type === 'block' && block.style === 'h2'); + + const headings: Array = allH2s + .map((block) => + 'children' in block && Array.isArray(block.children) ? block.children[0].text : null + ) + .filter(Boolean); + + return ( +
+ + + + +
+ + {headings.length ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/foundation/app/(Site)/blog/[slug]/HeadingOutline.tsx b/apps/foundation/app/(Site)/blog/[slug]/HeadingOutline.tsx new file mode 100644 index 00000000..236842cf --- /dev/null +++ b/apps/foundation/app/(Site)/blog/[slug]/HeadingOutline.tsx @@ -0,0 +1,44 @@ +'use client'; + +import Typography from '@session/ui/components/Typography'; +import { cn } from '@session/ui/lib/utils'; +import { navlinkVariants } from '@session/ui/components/NavLink'; + +function scrollToHeading(text: string) { + document.querySelectorAll('h2').forEach((heading) => { + if (text && heading.textContent && heading.textContent === text) { + heading.scrollIntoView({ + behavior: 'smooth', + }); + } + }); +} + +type HeadingOutlineProps = { + headings: Array; + title: string; +}; + +export default function HeadingOutline({ title, headings }: HeadingOutlineProps) { + return ( + + ); +} diff --git a/apps/foundation/app/(Site)/blog/[slug]/PostInfoBlock.tsx b/apps/foundation/app/(Site)/blog/[slug]/PostInfoBlock.tsx new file mode 100644 index 00000000..1bd5bd78 --- /dev/null +++ b/apps/foundation/app/(Site)/blog/[slug]/PostInfoBlock.tsx @@ -0,0 +1,79 @@ +import { SanityImage } from '@session/sanity-cms/components/SanityImage'; +import { client } from '@/lib/sanity/sanity.client'; +import Typography from '@session/ui/components/Typography'; +import { getLocale } from 'next-intl/server'; +import { cn } from '@session/ui/lib/utils'; +import { safeTry } from '@session/util-js/try'; +import logger from '@/lib/logger'; +import type { FormattedPostType } from '@session/sanity-cms/queries/getPost'; +import type { ReactNode } from 'react'; + +const getLocalizedPosedDate = async (date: Date) => { + const locale = await getLocale(); + return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric' }).format( + date + ); +}; +export type PostBlockProps = { + postInfo: Pick; + renderWithPriority?: boolean; + mobileImagePosition?: 'above' | 'below'; + columnAlways?: boolean; + className?: string; + children?: ReactNode; +}; + +export default async function PostInfoBlock({ + postInfo, + renderWithPriority, + mobileImagePosition = 'above', + columnAlways, + className, + children, +}: PostBlockProps) { + const { title, summary, featuredImage, author, date } = postInfo; + + let localizedPublishedAt = null; + if (date) { + const [err, res] = await safeTry(getLocalizedPosedDate(date)); + if (err) { + logger.error(err); + localizedPublishedAt = date.toLocaleDateString(); + } else { + localizedPublishedAt = res; + } + } + + return ( +
+
+ +
+
+ + {title} + + + {date ? : null} + {date ? '/' : null} + {author?.name ?
{author.name}
: null} +
+ {summary ?

{summary}

: null} + {children} +
+
+ ); +} diff --git a/apps/foundation/app/(Site)/blog/[slug]/page.tsx b/apps/foundation/app/(Site)/blog/[slug]/page.tsx new file mode 100644 index 00000000..94ce96f0 --- /dev/null +++ b/apps/foundation/app/(Site)/blog/[slug]/page.tsx @@ -0,0 +1,58 @@ +import { client } from '@/lib/sanity/sanity.client'; +import { notFound } from 'next/navigation'; +import { getPostsSlugs } from '@session/sanity-cms/queries/getPosts'; +import { getPostBySlug } from '@session/sanity-cms/queries/getPost'; +import logger from '@/lib/logger'; +import BlogPost from '@/app/(Site)/blog/[slug]/BlogPost'; + +/** + * Force static rendering and cache the data of a layout or page by forcing `cookies()`, `headers()` + * and `useSearchParams()` to return empty values. + * @see {@link https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic} + */ +export const dynamic = 'force-static'; +/** + * Dynamic segments not included in generateStaticParams are generated on demand. + * @see {@link https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams} + */ +export const dynamicParams = true; + +export async function generateStaticParams() { + const posts = await getPostsSlugs({ client }); + const slugs = new Set(posts.map((post) => post.slug.current)); + + if (slugs.size === 0) { + console.warn('No posts found. Not statically generating any posts.'); + } + + const postsToGenerate = Array.from(slugs); + logger.info(`Generating static params for ${postsToGenerate.length} posts`); + logger.info(postsToGenerate); + return postsToGenerate; +} + +type PageProps = { + params: { slug?: string }; +}; + +export default async function PostPage({ params }: PageProps) { + const slug = params.slug; + + if (!slug) { + logger.warn( + "No slug provided for post page, this means next.js couldn't find the post home page. Returning not found" + ); + return notFound(); + } + + logger.info(`Generating page for slug ${slug}`); + + const post = await getPostBySlug({ + client, + slug, + }); + + if (!post) return notFound(); + + return ; +} diff --git a/apps/foundation/app/(Site)/blog/page.tsx b/apps/foundation/app/(Site)/blog/page.tsx new file mode 100644 index 00000000..678e2c99 --- /dev/null +++ b/apps/foundation/app/(Site)/blog/page.tsx @@ -0,0 +1,75 @@ +import PostInfoBlock from '@/app/(Site)/blog/[slug]/PostInfoBlock'; +import { client } from '@/lib/sanity/sanity.client'; +import { getPostsWithMetadata } from '@session/sanity-cms/queries/getPosts'; +import logger from '@/lib/logger'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { BLOG, SANITY_SCHEMA_URL } from '@/lib/constants'; +import { cn } from '@session/ui/lib/utils'; +import { getTranslations } from 'next-intl/server'; +import Typography from '@session/ui/components/Typography'; + +/** + * Force static rendering and cache the data of a layout or page by forcing `cookies()`, `headers()` + * and `useSearchParams()` to return empty values. + * @see {@link https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic} + */ +export const dynamic = 'force-static'; + +async function ReadMoreText() { + const blogDictionary = await getTranslations('blog'); + return ( + + {blogDictionary('readMore')} + + ); +} + +export default async function BlogGridPage() { + const [latestPost, ...rest] = await getPostsWithMetadata({ client }); + + const blogDictionary = await getTranslations('blog'); + + if (!latestPost) { + logger.error('No latest post found'); + return notFound(); + } + + const linkClassName = cn( + 'group', + 'transition-all duration-300 ease-in-out motion-reduce:transition-none', + '[&_*]:transition-all [&_*]:duration-300 [&_*]:ease-in-out [&_*]:motion-reduce:transition-none', + '[&_img]:hover:brightness-125 [&_img]:hover:saturate-150 [&_h1]:hover:text-session-green-dark [&_h2]:hover:text-session-green-dark' + ); + + return ( +
+ + + + + + + {blogDictionary('morePosts')} + +
+ {rest.map((post, index) => ( + + + + + + ))} +
+
+ ); +}