diff --git a/src/components/Blog/Authors/index.tsx b/src/components/Blog/Authors/index.tsx index 45f1f8039..a37d47ef3 100644 --- a/src/components/Blog/Authors/index.tsx +++ b/src/components/Blog/Authors/index.tsx @@ -1,30 +1,20 @@ -import { Avatar, AvatarGroup, Typography } from '@mui/material' -import css from '../styles.module.css' +import { Avatar, AvatarGroup } from '@mui/material' import type { Entry } from 'contentful' import type { TypeAuthorSkeleton } from '@/contentful/types' import { isAsset } from '@/lib/typeGuards' -import { formatAuthorsList } from '@/components/Blog/utils/formatAuthorsList' export type AuthorsProps = Entry[] -const Authors = ({ authors }: { authors: AuthorsProps }) => { - return ( -
- - {authors.map((author) => { - const { name, picture } = author.fields +const Authors = ({ authors }: { authors: AuthorsProps }) => ( + + {authors.map((author) => { + const { name, picture } = author.fields - return isAsset(picture) && picture.fields.file?.url ? ( - - ) : undefined - })} - - - - {formatAuthorsList(authors)} - -
- ) -} + return isAsset(picture) && picture.fields.file?.url ? ( + + ) : undefined + })} + +) export default Authors diff --git a/src/components/Blog/BreadcrumbsNav/index.tsx b/src/components/Blog/BreadcrumbsNav/index.tsx index 47014b9a5..67238a5e6 100644 --- a/src/components/Blog/BreadcrumbsNav/index.tsx +++ b/src/components/Blog/BreadcrumbsNav/index.tsx @@ -1,13 +1,12 @@ +import type { ReactNode } from 'react' +import type { UrlObject } from 'url' import { Breadcrumbs, Typography } from '@mui/material' import Link from 'next/link' -import css from './styles.module.css' -import { type UrlObject } from 'url' -import CategoryIcon from '@/public/images/Blog/category-icon.svg' -import { type ReactNode } from 'react' import { AppRoutes } from '@/config/routes' +import AngleIcon from '@/public/images/angle-icon.svg' +import css from './styles.module.css' -const TYPOGRAPHY_VARIANT = 'caption' -const TYPOGRAPHY_COLOR = 'text.primary' +const TYPOGRAPHY_VARIANT = 'body2' type BreadcrumbsType = { category: string @@ -16,7 +15,7 @@ type BreadcrumbsType = { const createBreadcrumb = (key: string, text: ReactNode, linkProps: string | UrlObject) => ( - + {text} @@ -25,21 +24,17 @@ const createBreadcrumb = (key: string, text: ReactNode, linkProps: string | UrlO const BreadcrumbsNav = ({ category, title }: BreadcrumbsType) => { const breadcrumbs = [ createBreadcrumb('1', 'Blog', { pathname: AppRoutes.blog.index }), - createBreadcrumb( - '2', -
- - {category} -
, - { pathname: AppRoutes.blog.index, query: { category } }, - ), - + createBreadcrumb('2', category, { + pathname: AppRoutes.blog.index, + query: { category }, + }), + {title} , ] return ( - + } className={css.breadcrumbs}> {breadcrumbs} ) diff --git a/src/components/Blog/BreadcrumbsNav/styles.module.css b/src/components/Blog/BreadcrumbsNav/styles.module.css index 688b20cbb..38fe3996f 100644 --- a/src/components/Blog/BreadcrumbsNav/styles.module.css +++ b/src/components/Blog/BreadcrumbsNav/styles.module.css @@ -1,23 +1,26 @@ -.breadcrumbs, +.breadcrumbs li { + display: flex; + align-items: center; +} + .breadcrumbs a { - color: var(--mui-palette-text-primary); - margin-bottom: 24px; + transition-duration: var(--transition-duration); } .breadcrumbs a:hover { + color: var(--mui-palette-primary-light); text-decoration: underline; } .breadcrumbs :global .MuiBreadcrumbs-separator { - margin: 0 4px; + width: 8px; + height: 8px; } -.breadcrumbs :global .MuiTypography-root { - line-height: inherit; -} - -.category { - display: flex; - gap: 4px; - align-items: center; +.title { + width: 290px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--mui-palette-primary-light); } diff --git a/src/components/Blog/Card/index.tsx b/src/components/Blog/Card/index.tsx index 64ca92730..f739a7891 100644 --- a/src/components/Blog/Card/index.tsx +++ b/src/components/Blog/Card/index.tsx @@ -1,16 +1,15 @@ import Image from 'next/image' import Link from 'next/link' import { Box, Typography } from '@mui/material' -import css from './styles.module.css' -import { calculateReadingTimeInMin } from '@/components/Blog/utils/calculateReadingTime' import Tags from '@/components/Blog/Tags' -import CategoryIcon from '@/public/images/Blog/category-icon.svg' import { isAsset } from '@/lib/typeGuards' import type { BlogPostEntry } from '@/config/types' +import Meta from '@/components/Blog/Meta' import { AppRoutes } from '@/config/routes' +import css from './styles.module.css' const Card = (props: BlogPostEntry) => { - const { slug, title, content, coverImage, tags, category } = props.fields + const { slug, title, coverImage, tags } = props.fields return (
@@ -27,13 +26,7 @@ const Card = (props: BlogPostEntry) => { ) : undefined}
-
- - - {category} - - {calculateReadingTimeInMin(content)} -
+
{title} diff --git a/src/components/Blog/Card/styles.module.css b/src/components/Blog/Card/styles.module.css index 0cf043aa4..81a0913e1 100644 --- a/src/components/Blog/Card/styles.module.css +++ b/src/components/Blog/Card/styles.module.css @@ -4,13 +4,26 @@ display: flex; flex-direction: column; border-radius: 8px; - border: 1px solid var(--mui-palette-border-main); - overflow: hidden; + outline: 1px solid var(--mui-palette-border-main); transition-duration: var(--transition-duration); } -.postCard:hover { - border: 1px solid var(--mui-palette-primary-main); +.postCard::before { + content: ''; + position: absolute; + border-radius: 11px; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + z-index: -1; + background: linear-gradient(to bottom left, #12ff80, #5fddff); + opacity: 0; +} + +.postCard:hover::before { + opacity: 1; + box-shadow: 10px 10px 25px 0 rgba(18, 255, 128, 0.2); } .link { @@ -27,6 +40,8 @@ object-position: center left; border-bottom: 1px solid var(--mui-palette-border-main); transition-duration: var(--transition-duration); + border-top-left-radius: 8px; + border-top-right-radius: 8px; } .cardBody { @@ -34,18 +49,16 @@ display: flex; flex-direction: column; flex-grow: 1; -} - -.meta { - display: flex; - gap: 8px; - align-items: center; + background-color: var(--mui-palette-background-default); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; } .title { margin-top: 16px; overflow: hidden; display: -webkit-box; + line-clamp: 3; -webkit-line-clamp: 3; -webkit-box-orient: vertical; } diff --git a/src/components/Blog/ContentsTable/index.tsx b/src/components/Blog/ContentsTable/index.tsx index 9636ac896..8537f9f11 100644 --- a/src/components/Blog/ContentsTable/index.tsx +++ b/src/components/Blog/ContentsTable/index.tsx @@ -5,7 +5,7 @@ import { useMemo } from 'react' import { scrollToElement } from '@/lib/scrollSmooth' import { Typography } from '@mui/material' import Link from 'next/link' -import css from '../styles.module.css' +import css from './styles.module.css' const ContentsTable = ({ content }: { content: ContentfulDocument }) => { const handleContentTableClick = (e: React.MouseEvent, target: string) => { @@ -25,22 +25,30 @@ const ContentsTable = ({ content }: { content: ContentfulDocument }) => { [content], ) + if (!headings.length) return null + return ( -
    - {headings.map((heading) => { - const headingKey = kebabCase(heading.id) - - return ( -
  • - - handleContentTableClick(e, headingKey)} href={`#${headingKey}`}> - {heading.text} - - -
  • - ) - })} -
+
+ + Table of contents + + +
    + {headings.map((heading) => { + const headingKey = kebabCase(heading.id) + + return ( +
  1. + + handleContentTableClick(e, headingKey)} href={`#${headingKey}`}> + {heading.text} + + +
  2. + ) + })} +
+
) } diff --git a/src/components/Blog/ContentsTable/styles.module.css b/src/components/Blog/ContentsTable/styles.module.css new file mode 100644 index 000000000..e01caca5f --- /dev/null +++ b/src/components/Blog/ContentsTable/styles.module.css @@ -0,0 +1,30 @@ +.contentsTable { + margin: 0; + border: 1px solid var(--mui-palette-border-light); + border-radius: 8px; + padding: 24px; + margin-bottom: 24px; +} + +.contentsTable ol { + list-style-type: none; + padding-left: 0px; +} + +.contentsTable li { + counter-increment: step-counter; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.contentsTable li::before { + content: counter(step-counter) ' '; + white-space: pre; + color: var(--mui-palette-primary-light); +} + +.contentsTable a:hover { + text-decoration: underline; + color: var(--mui-palette-primary-main); +} diff --git a/src/components/Blog/FeaturedPost/index.tsx b/src/components/Blog/FeaturedPost/index.tsx index b0527b7a3..a708871a5 100644 --- a/src/components/Blog/FeaturedPost/index.tsx +++ b/src/components/Blog/FeaturedPost/index.tsx @@ -2,16 +2,14 @@ import Image from 'next/image' import Tags from '@/components/Blog/Tags' import { Box, Grid, Link, Typography } from '@mui/material' import css from './styles.module.css' -import { formatBlogDate } from '@/components/Blog/utils/formatBlogDate' -import { calculateReadingTimeInMin } from '@/components/Blog/utils/calculateReadingTime' import type { BlogPostEntry } from '@/config/types' import { isAsset } from '@/lib/typeGuards' -import CategoryIcon from '@/public/images/Blog/category-icon.svg' import { AppRoutes } from '@/config/routes' import { containsTag, PRESS_RELEASE_TAG } from '@/lib/containsTag' +import Meta from '@/components/Blog/Meta' const FeaturedPost = ({ post }: { post: BlogPostEntry }) => { - const { slug, coverImage, category, date, title, excerpt, tags, content } = post.fields + const { slug, coverImage, title, excerpt, tags } = post.fields const isPressRelease = containsTag(tags, PRESS_RELEASE_TAG) @@ -38,19 +36,7 @@ const FeaturedPost = ({ post }: { post: BlogPostEntry }) => { -
-
- - - - {category} - - - {calculateReadingTimeInMin(content)} -
- - {formatBlogDate(date)} -
+ {title} diff --git a/src/components/Blog/FeaturedPost/styles.module.css b/src/components/Blog/FeaturedPost/styles.module.css index dd63158b9..4ab77f35c 100644 --- a/src/components/Blog/FeaturedPost/styles.module.css +++ b/src/components/Blog/FeaturedPost/styles.module.css @@ -22,20 +22,6 @@ gap: 16px; } -.meta { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; -} - -.metaStart { - display: flex; - flex-wrap: nowrap; - align-items: center; - gap: 8px; -} - .title, .excerpt { overflow: hidden; diff --git a/src/components/Blog/Meta/index.tsx b/src/components/Blog/Meta/index.tsx new file mode 100644 index 000000000..70a5c1a09 --- /dev/null +++ b/src/components/Blog/Meta/index.tsx @@ -0,0 +1,23 @@ +import { Typography } from '@mui/material' +import type { BlogPostEntry } from '@/config/types' +import css from './styles.module.css' +import { formatBlogDate } from '@/components/Blog/utils/formatBlogDate' +import { calculateReadingTimeInMin } from '@/components/Blog/utils/calculateReadingTime' + +const Meta = ({ post }: { post: BlogPostEntry }) => { + const { category, date, content } = post.fields + + return ( +
+ + {category} + + + {formatBlogDate(date)} + + {calculateReadingTimeInMin(content)} min read +
+ ) +} + +export default Meta diff --git a/src/components/Blog/Meta/styles.module.css b/src/components/Blog/Meta/styles.module.css new file mode 100644 index 000000000..a1f2f95a0 --- /dev/null +++ b/src/components/Blog/Meta/styles.module.css @@ -0,0 +1,19 @@ +.meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 8px; +} + +.meta > *:not(:last-of-type)::after { + content: '•'; + color: var(--mui-palette-primary-light); + margin-left: 8px; +} + +.category { + background: linear-gradient(90deg, var(--mui-palette-info-main) 0%, var(--mui-palette-primary-main) 100%); + background-clip: text; + color: transparent; +} diff --git a/src/components/Blog/Post/index.tsx b/src/components/Blog/Post/index.tsx index 1afc9aae3..8099a1384 100644 --- a/src/components/Blog/Post/index.tsx +++ b/src/components/Blog/Post/index.tsx @@ -1,9 +1,6 @@ -import Image from 'next/image' -import { Box, Button, Container, Divider, Grid, Typography } from '@mui/material' +import { Button, Container, Divider, Grid, Typography } from '@mui/material' import { type Entry } from 'contentful' import type { TypeAuthorSkeleton } from '@/contentful/types' -import { formatBlogDate } from '@/components/Blog/utils/formatBlogDate' -import { calculateReadingTimeInMin } from '@/components/Blog/utils/calculateReadingTime' import { isAsset, isEntryTypeAuthor, isEntryTypePost } from '@/lib/typeGuards' import BlogLayout from '@/components/Blog/Layout' import ProgressBar from '@/components/Blog/ProgressBar' @@ -14,13 +11,14 @@ import RichText from '@/components/common/RichText' import ContentsTable from '@/components/Blog/ContentsTable' import Socials from '@/components/Blog/Socials' import RelatedPosts from '@/components/Blog/RelatedPosts' -import CategoryIcon from '@/public/images/Blog/category-icon.svg' import { type Document as ContentfulDocument } from '@contentful/rich-text-types' import css from '../styles.module.css' import { PRESS_RELEASE_TAG, containsTag } from '@/lib/containsTag' import { COMMS_EMAIL } from '@/config/constants' import { useBlogPost } from '@/hooks/useBlogPost' import type { BlogPostEntry } from '@/config/types' +import Meta from '@/components/Blog/Meta' +import { formatAuthorsList } from '@/components/Blog/utils/formatAuthorsList' export type BlogPostProps = { blogPost: BlogPostEntry @@ -42,55 +40,49 @@ const BlogPost = ({ blogPost }: BlogPostProps) => { -
-
-
- - - {category} - -
- {calculateReadingTimeInMin(content)} -
- {formatBlogDate(date)} -
- {title} - - {excerpt} - +
+
+ + +
+ {formatAuthorsList(authorsList)} + + +
+
- - +
- + {isAsset(coverImage) && coverImage.fields.file?.url ? ( + {coverImage.fields.title + ) : undefined} - +
+ + - - + + - - {isAsset(coverImage) && coverImage.fields.file?.url ? ( - {coverImage.fields.title - ) : undefined} +
+ + {excerpt} + - + -
- -
+ + + +
+
-
+
@@ -117,11 +109,14 @@ const Sidebar = ({
) + +const SharingLinks = ({ + title, + authors, +}: { + title: string + authors: Entry[] +}) => ( +
+ + Share this + + + +
+) diff --git a/src/components/Blog/styles.module.css b/src/components/Blog/styles.module.css index a8677de4f..83c12757c 100644 --- a/src/components/Blog/styles.module.css +++ b/src/components/Blog/styles.module.css @@ -12,39 +12,38 @@ background-image: linear-gradient(260.13deg, #12ff80 1.24%, #5fddff 102.14%); } -.meta { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - margin-top: 32px; +.title { + overflow-wrap: break-word; } -.metaStart { - display: flex; - align-items: center; - gap: 14px; +.title { + margin-top: 16px; } -.category { +.meta { display: flex; - gap: 8px; - align-items: center; + flex-direction: column; + align-items: flex-start; + margin-top: 16px; + margin-bottom: 40px; + gap: 16px; } -.title { - overflow-wrap: break-word; +.info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; } -.title, .excerpt { - margin-top: 16px; + margin-bottom: 24px; } .tagsWrapper { display: flex; flex-wrap: wrap; - gap: 16px; + gap: 8px; } .chip { @@ -52,21 +51,6 @@ height: 24px; } -.authors { - display: flex; - align-items: center; - gap: 8px; -} - -.avatarGroup { - margin: 16px 16px 16px 0px; - justify-content: flex-end; -} - -.divider { - margin-top: 8px; -} - .content { margin-top: 32px; scroll-snap-type: y mandatory; @@ -77,13 +61,17 @@ font-weight: bold; } -.postBody a, -.contentsTable a { +.coverImage { + max-width: none; + width: 100%; + border-radius: 16px; +} + +.postBody a { color: var(--mui-palette-primary-main); } -.postBody a:hover, -.contentsTable a:hover { +.postBody a:hover { text-decoration: underline; } @@ -126,18 +114,38 @@ color: var(--mui-palette-text-dark); } +.sidebarLinks { + display: none; +} + +.sharingLinks a { + color: var(--mui-palette-text-primary); +} + @media (min-width: 900px) { .title { margin-top: 24px; overflow-wrap: normal; } - .avatarGroup { - margin: 24px 24px 24px 0px; + .meta { + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + } + + .info { + flex-direction: row; + align-items: center; } .content { - flex-direction: row-reverse; + margin-top: 0px; + padding: 40px 50px; + } + + .excerpt { + margin-bottom: 56px; } .showInMd { @@ -152,4 +160,9 @@ position: sticky; top: calc(var(--header-height) + 24px); } + + .sidebarLinks { + display: block; + margin-top: 32px; + } } diff --git a/src/components/Blog/utils/__test__/formatBlogDate.test.ts b/src/components/Blog/utils/__test__/formatBlogDate.test.ts index 2cace4bf4..bd9f01b8b 100644 --- a/src/components/Blog/utils/__test__/formatBlogDate.test.ts +++ b/src/components/Blog/utils/__test__/formatBlogDate.test.ts @@ -1,31 +1,17 @@ import { formatBlogDate } from '@/components/Blog/utils/formatBlogDate' describe('formatBlogDate', () => { - it('formats a valid date with a st day suffix', () => { - const inputDate = '2023-12-01T00:00+00:00' - const formattedDate = formatBlogDate(inputDate) - - expect(formattedDate).toBe('1st December, 2023') - }) - - it('formats a valid date with a nd day suffix', () => { - const inputDate = '2023-12-02T00:00+00:00' - const formattedDate = formatBlogDate(inputDate) - - expect(formattedDate).toBe('2nd December, 2023') - }) - - it('formats a valid date with a rd day suffix', () => { - const inputDate = '2023-12-03T00:00+00:00' + it('formats a valid date', () => { + const inputDate = '2023-12-08T00:00+00:00' const formattedDate = formatBlogDate(inputDate) - expect(formattedDate).toBe('3rd December, 2023') + expect(formattedDate).toBe('Dec 8, 2023') }) - it('formats a valid date with a th day suffix', () => { - const inputDate = '2023-12-08T00:00+00:00' + it('returns "Invalid Date" for unexisting date', () => { + const inputDate = '2023-13-03T00:00+00:00' const formattedDate = formatBlogDate(inputDate) - expect(formattedDate).toBe('8th December, 2023') + expect(formattedDate).toBe('Invalid Date') }) }) diff --git a/src/components/Blog/utils/calculateReadingTime.ts b/src/components/Blog/utils/calculateReadingTime.ts index 65b16390f..722b8cf0d 100644 --- a/src/components/Blog/utils/calculateReadingTime.ts +++ b/src/components/Blog/utils/calculateReadingTime.ts @@ -15,5 +15,5 @@ export function calculateReadingTimeInMin(content: Document) { const wordCount = words(allText).length - return `${Math.round(wordCount / averageWPM)}min` + return Math.round(wordCount / averageWPM) } diff --git a/src/components/Blog/utils/formatBlogDate.ts b/src/components/Blog/utils/formatBlogDate.ts index 12062947d..1b435a529 100644 --- a/src/components/Blog/utils/formatBlogDate.ts +++ b/src/components/Blog/utils/formatBlogDate.ts @@ -1,37 +1,15 @@ -const ordinalSuffixes = { - one: 'st', - two: 'nd', - few: 'rd', - other: 'th', - zero: 'th', - many: undefined, -} - -// Formatters -const ordinalPluralRules = new Intl.PluralRules('en', { type: 'ordinal' }) -const dateFormat = new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric' }) - -function appendOrdinalSuffix(day: number) { - const ordinal = ordinalPluralRules.select(day) - const suffix = ordinalSuffixes[ordinal] - - return `${day}${suffix}` -} - /** * Formats a date string. * * @param {string} inputDate - The input date string to be formatted. - * @returns {string} - The formatted date in the "8th December, 2023" style. + * @returns {string} - The formatted date in the "Dec 8, 2023" style. */ export function formatBlogDate(inputDate: string) { const date = new Date(inputDate) - const parts = dateFormat.formatToParts(date) - - const dayName = parts.find((p) => p.type === 'day')?.value - const dayWithSuffix = appendOrdinalSuffix(Number(dayName)) - const month = parts.find((p) => p.type === 'month')?.value - const year = parts.find((p) => p.type === 'year')?.value - return `${dayWithSuffix} ${month}, ${year}` + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) }