diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-13 19:29:41 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-05-13 22:35:55 +0200 |
| commit | 5f3799ee75b3ac5cffe726023d8e5df129b919dd (patch) | |
| tree | 573fddbe37e2257d67d81693ed0be5c46f049f2a /src | |
| parent | 06ea295857e508a830669cb402d2156204309b1e (diff) | |
chore: add Thematic page
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/organisms/layout/posts-list.module.scss | 11 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.tsx | 8 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.module.scss | 1 | ||||
| -rw-r--r-- | src/pages/blog/index.tsx | 28 | ||||
| -rw-r--r-- | src/pages/thematique/[slug].tsx | 247 | ||||
| -rw-r--r-- | src/services/graphql/thematics.query.ts | 7 | ||||
| -rw-r--r-- | src/services/graphql/thematics.ts | 133 | ||||
| -rw-r--r-- | src/utils/helpers/pages.ts | 47 |
8 files changed, 460 insertions, 22 deletions
diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss index 8021b2b..a006914 100644 --- a/src/components/organisms/layout/posts-list.module.scss +++ b/src/components/organisms/layout/posts-list.module.scss @@ -23,6 +23,17 @@ } .year { + padding-bottom: fun.convert-px(3); + background: linear-gradient( + to top, + var(--color-primary-dark) 0.3rem, + transparent 0.3rem + ) + 0 0 / 3rem 100% no-repeat; + font-size: var(--font-size-2xl); + font-weight: 500; + text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); + @include mix.media("screen") { @include mix.dimensions("md") { grid-column: 1; diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx index 4d77d20..9dfe254 100644 --- a/src/components/organisms/layout/posts-list.tsx +++ b/src/components/organisms/layout/posts-list.tsx @@ -122,18 +122,20 @@ const PostsList: FC<PostsListProps> = ({ * @returns {JSX.Element | JSX.Element[]} The posts list. */ const getPosts = (): JSX.Element | JSX.Element[] => { - if (!byYear) return getList(posts); + const firstLevel = titleLevel || 2; + if (!byYear) return getList(posts, firstLevel); const postsPerYear = sortPostsByYear(posts); const years = Object.keys(postsPerYear).reverse(); + const nextLevel = (firstLevel + 1) as HeadingLevel; return years.map((year) => { return ( <section key={year} className={styles.section}> - <Heading level={2} className={styles.year}> + <Heading level={firstLevel} className={styles.year}> {year} </Heading> - {getList(postsPerYear[year], titleLevel)} + {getList(postsPerYear[year], nextLevel)} </section> ); }); diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss index 5f22fbb..9d28bc6 100644 --- a/src/components/organisms/layout/summary.module.scss +++ b/src/components/organisms/layout/summary.module.scss @@ -84,6 +84,7 @@ margin: 0; background: none; color: inherit; + font-size: var(--font-size-2xl); text-shadow: none; } diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 05e73db..3acf6a9 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -24,6 +24,10 @@ import { } from '@ts/types/raw-data'; import { settings } from '@utils/config'; import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, +} from '@utils/helpers/pages'; import usePagination from '@utils/hooks/use-pagination'; import useSettings from '@utils/hooks/use-settings'; import { GetStaticProps, NextPage } from 'next'; @@ -203,20 +207,6 @@ const BlogPage: NextPage<BlogPageProps> = ({ setSize((prevSize) => prevSize + 1); }; - const getLinksListItems = ( - rawData: RawThematicPreview[] | RawTopicPreview[], - kind: 'thematic' | 'topic' - ): LinksListItems[] => { - const baseUrl = kind === 'thematic' ? '/thematique/' : '/sujet/'; - - return rawData.map((taxonomy) => { - return { - name: taxonomy.title, - url: `${baseUrl}${taxonomy.slug}`, - }; - }); - }; - const thematicsListTitle = intl.formatMessage({ defaultMessage: 'Thematics', description: 'BlogPage: thematics list widget title', @@ -251,13 +241,19 @@ const BlogPage: NextPage<BlogPageProps> = ({ widgets={[ <LinksListWidget key="thematics-list" - items={getLinksListItems(thematicsList, 'thematic')} + items={getLinksListItems( + thematicsList.map(getPageLinkFromRawData), + 'thematic' + )} title={thematicsListTitle} level={2} />, <LinksListWidget key="topics-list" - items={getLinksListItems(topicsList, 'topic')} + items={getLinksListItems( + topicsList.map(getPageLinkFromRawData), + 'topic' + )} title={topicsListTitle} level={2} />, diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx new file mode 100644 index 0000000..dd7e80d --- /dev/null +++ b/src/pages/thematique/[slug].tsx @@ -0,0 +1,247 @@ +import Heading from '@components/atoms/headings/heading'; +import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb'; +import PostsList, { type Post } from '@components/organisms/layout/posts-list'; +import LinksListWidget from '@components/organisms/widgets/links-list-widget'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; +import { + getAllThematicsSlugs, + getThematicBySlug, + getThematicsPreview, + getTotalThematics, +} from '@services/graphql/thematics'; +import { type Article, type PageLink, type Thematic } from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, + getPostMeta, +} from '@utils/helpers/pages'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticPaths, GetStaticProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { ParsedUrlQuery } from 'querystring'; +import { useIntl } from 'react-intl'; +import { Article as ArticleSchema, Graph, WebPage } from 'schema-dts'; + +export type ThematicPageProps = { + currentThematic: Thematic; + thematics: PageLink[]; + translation: Messages; +}; + +const ThematicPage: NextPage<ThematicPageProps> = ({ + currentThematic, + thematics, +}) => { + const { content, intro, meta, slug, title } = currentThematic; + const { articles, dates, seo, topics } = meta; + const intl = useIntl(); + const homeLabel = intl.formatMessage({ + defaultMessage: 'Home', + description: 'Breadcrumb: home label', + id: 'j5k9Fe', + }); + const blogLabel = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'Breadcrumb: blog label', + id: 'Es52wh', + }); + const breadcrumb: BreadcrumbItem[] = [ + { id: 'home', name: homeLabel, url: '/' }, + { id: 'blog', name: blogLabel, url: '/blog' }, + { id: 'thematic', name: title, url: `/thematique/${slug}` }, + ]; + + const headerMeta: PageLayoutProps['headerMeta'] = { + publication: { date: dates.publication }, + update: dates.update ? { date: dates.update } : undefined, + }; + + const { website } = useSettings(); + const { asPath } = useRouter(); + const pageUrl = `${website.url}${asPath}`; + const pagePublicationDate = new Date(dates.publication); + const pageUpdateDate = dates.update ? new Date(dates.update) : undefined; + + const webpageSchema: WebPage = { + '@id': `${pageUrl}`, + '@type': 'WebPage', + breadcrumb: { '@id': `${website.url}/#breadcrumb` }, + name: seo.title, + description: seo.description, + inLanguage: website.locales.default, + reviewedBy: { '@id': `${website.url}/#branding` }, + url: `${website.url}`, + }; + + const articleSchema: ArticleSchema = { + '@id': `${website.url}/#thematic`, + '@type': 'Article', + name: title, + description: intro, + author: { '@id': `${website.url}/#branding` }, + copyrightYear: pagePublicationDate.getFullYear(), + creator: { '@id': `${website.url}/#branding` }, + dateCreated: pagePublicationDate.toISOString(), + dateModified: pageUpdateDate && pageUpdateDate.toISOString(), + datePublished: pagePublicationDate.toISOString(), + editor: { '@id': `${website.url}/#branding` }, + headline: title, + inLanguage: website.locales.default, + isPartOf: { '@id': `${website.url}/blog` }, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${pageUrl}` }, + subjectOf: { '@id': `${website.url}/blog` }, + }; + + const schemaJsonLd: Graph = { + '@context': 'https://schema.org', + '@graph': [webpageSchema, articleSchema], + }; + + const getPosts = (array: Article[]): Post[] => { + return array.map((article) => { + const { + intro: articleIntro, + meta: articleMeta, + slug: articleSlug, + ...remainingData + } = article; + + const { cover, ...remainingMeta } = articleMeta; + + return { + cover, + excerpt: articleIntro, + meta: getPostMeta(remainingMeta), + url: `/article/${articleSlug}`, + ...remainingData, + }; + }); + }; + + const thematicsListTitle = intl.formatMessage({ + defaultMessage: 'Other thematics', + description: 'ThematicPage: other thematics list widget title', + id: 'KVSWGP', + }); + + const topicsListTitle = intl.formatMessage({ + defaultMessage: 'Related topics', + description: 'ThematicPage: related topics list widget title', + id: '/42Z0z', + }); + + return ( + <> + <Head> + <title>{seo.title}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${pageUrl}`} /> + <meta property="og:type" content="article" /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={intro} /> + </Head> + <Script + id="schema-project" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <PageLayout + breadcrumb={breadcrumb} + title={title} + intro={intro} + headerMeta={headerMeta} + widgets={ + topics + ? [ + <LinksListWidget + key="thematics" + items={getLinksListItems(thematics, 'thematic')} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="related-topics" + items={getLinksListItems(topics, 'topic')} + title={topicsListTitle} + level={2} + />, + ] + : [] + } + > + <div dangerouslySetInnerHTML={{ __html: content }} /> + {articles && ( + <> + <Heading level={2}> + {intl.formatMessage( + { + defaultMessage: 'All posts in {thematicName}', + description: 'ThematicPage: posts list heading', + id: 'LszkU6', + }, + { thematicName: title } + )} + </Heading> + <PostsList + posts={getPosts(articles)} + total={articles.length} + titleLevel={3} + byYear={true} + /> + </> + )} + </PageLayout> + </> + ); +}; + +interface ThematicParams extends ParsedUrlQuery { + slug: string; +} + +export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({ + locale, + params, +}) => { + const currentThematic = await getThematicBySlug( + params!.slug as ThematicParams['slug'] + ); + const totalThematics = await getTotalThematics(); + const allThematicsEdges = await getThematicsPreview({ + first: totalThematics, + }); + const allThematics = allThematicsEdges.edges.map((edge) => + getPageLinkFromRawData(edge.node) + ); + const translation = await loadTranslation(locale); + + return { + props: { + currentThematic: JSON.parse(JSON.stringify(currentThematic)), + thematics: allThematics.filter( + (thematic) => thematic.slug !== (params!.slug as ThematicParams['slug']) + ), + translation, + }, + }; +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const slugs = await getAllThematicsSlugs(); + const paths = slugs.map((slug) => { + return { params: { slug } }; + }); + + return { + paths, + fallback: false, + }; +}; + +export default ThematicPage; diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts index db8e751..3b3ebd6 100644 --- a/src/services/graphql/thematics.query.ts +++ b/src/services/graphql/thematics.query.ts @@ -15,6 +15,13 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) { } } } + author { + node { + gravatarUrl + name + url + } + } commentCount contentParts { beforeMore diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts index 7003e08..e526db8 100644 --- a/src/services/graphql/thematics.ts +++ b/src/services/graphql/thematics.ts @@ -1,6 +1,20 @@ -import { RawThematicPreview, TotalItems } from '@ts/types/raw-data'; +import { PageLink, Slug, Thematic } from '@ts/types/app'; +import { + RawArticle, + RawThematic, + RawThematicPreview, + TotalItems, +} from '@ts/types/raw-data'; +import { getImageFromRawData } from '@utils/helpers/images'; +import { getPageLinkFromRawData } from '@utils/helpers/pages'; import { EdgesResponse, EdgesVars, fetchAPI, getAPIUrl } from './api'; -import { thematicsListQuery, totalThematicsQuery } from './thematics.query'; +import { getArticleFromRawData } from './articles'; +import { + thematicBySlugQuery, + thematicsListQuery, + thematicsSlugQuery, + totalThematicsQuery, +} from './thematics.query'; /** * Retrieve the total number of thematics. @@ -32,3 +46,118 @@ export const getThematicsPreview = async ( return response.thematics; }; + +/** + * Convert raw data to an Thematic object. + * + * @param {RawThematic} data - The page raw data. + * @returns {Thematic} The page data. + */ +export const getThematicFromRawData = (data: RawThematic): Thematic => { + const { + acfThematics, + contentParts, + databaseId, + date, + featuredImage, + info, + modified, + slug, + title, + seo, + } = data; + + /** + * Retrieve an array of related topics. + * + * @param posts - The thematic posts. + * @returns {PageLink[]} An array of topics links. + */ + const getRelatedTopics = (posts: RawArticle[]): PageLink[] => { + const topics: PageLink[] = []; + + posts.forEach((post) => { + if (post.acfPosts.postsInTopic) { + post.acfPosts.postsInTopic.forEach((topic) => + topics.push(getPageLinkFromRawData(topic)) + ); + } + }); + + const topicsIds = topics.map((topic) => topic.id); + const uniqueTopics = topics.filter( + ({ id }, index) => !topicsIds.includes(id, index + 1) + ); + const sortTopicByName = (a: PageLink, b: PageLink) => { + var nameA = a.name.toUpperCase(); // ignore upper and lowercase + var nameB = b.name.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + + // names must be equal + return 0; + }; + + return uniqueTopics.sort(sortTopicByName); + }; + + return { + content: contentParts.afterMore, + id: databaseId, + intro: contentParts.beforeMore, + meta: { + articles: acfThematics.postsInThematic.map((post) => + getArticleFromRawData(post) + ), + cover: featuredImage?.node + ? getImageFromRawData(featuredImage.node) + : undefined, + dates: { publication: date, update: modified }, + readingTime: info.readingTime, + seo: { + description: seo?.metaDesc || '', + title: seo?.title || '', + }, + topics: getRelatedTopics(acfThematics.postsInThematic), + wordsCount: info.wordsCount, + }, + slug, + title, + }; +}; + +/** + * Retrieve a Thematic object by slug. + * + * @param {string} slug - The thematic slug. + * @returns {Promise<Article>} The requested thematic. + */ +export const getThematicBySlug = async (slug: string): Promise<Thematic> => { + const response = await fetchAPI<RawThematic, typeof thematicBySlugQuery>({ + api: getAPIUrl(), + query: thematicBySlugQuery, + variables: { slug }, + }); + + return getThematicFromRawData(response.thematic); +}; + +/** + * Retrieve all the thematics slugs. + * + * @returns {Promise<string[]>} - An array of thematics slugs. + */ +export const getAllThematicsSlugs = async (): Promise<string[]> => { + const totalThematics = await getTotalThematics(); + const response = await fetchAPI<Slug, typeof thematicsSlugQuery>({ + api: getAPIUrl(), + query: thematicsSlugQuery, + variables: { first: totalThematics }, + }); + + return response.thematics.edges.map((edge) => edge.node.slug); +}; diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts index d757f8c..93582f0 100644 --- a/src/utils/helpers/pages.ts +++ b/src/utils/helpers/pages.ts @@ -1,4 +1,6 @@ -import { type PageLink } from '@ts/types/app'; +import { type Post } from '@components/organisms/layout/posts-list'; +import { type LinksListItems } from '@components/organisms/widgets/links-list-widget'; +import { type Meta, type PageLink } from '@ts/types/app'; import { type RawThematicPreview, type RawTopicPreview, @@ -24,3 +26,46 @@ export const getPageLinkFromRawData = ( slug, }; }; + +/** + * Convert page link data to an array of links items. + * + * @param {PageLink[]} links - An array of page links. + * @param {'thematic'|'topic'} kind - The page links kind. + * @returns {LinksListItem[]} An array of links items. + */ +export const getLinksListItems = ( + links: PageLink[], + kind: 'thematic' | 'topic' +): LinksListItems[] => { + const baseUrl = kind === 'thematic' ? '/thematique/' : '/sujet/'; + + return links.map((link) => { + return { + name: link.name, + url: `${baseUrl}${link.slug}`, + }; + }); +}; + +/** + * Retrieve the formatted meta. + * + * @param {Meta<'article'>} meta - The article meta. + * @returns {Post['meta']} The formatted meta. + */ +export const getPostMeta = (meta: Meta<'article'>): Post['meta'] => { + const { commentsCount, dates, thematics, topics, wordsCount } = meta; + + return { + commentsCount, + dates, + readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true }, + thematics: thematics?.map((thematic) => { + return { ...thematic, url: `/thematique/${thematic.slug}` }; + }), + topics: topics?.map((topic) => { + return { ...topic, url: `/sujet/${topic.slug}` }; + }), + }; +}; |
