diff --git a/frontend/apps/service-site/.storybook/main.ts b/frontend/apps/service-site/.storybook/main.ts index d0e93ab3f..ded53231d 100644 --- a/frontend/apps/service-site/.storybook/main.ts +++ b/frontend/apps/service-site/.storybook/main.ts @@ -18,6 +18,10 @@ const config: StorybookConfig = { config.resolve.alias = { ...config.resolve.alias, '@': path.resolve(__dirname, '../src'), + 'contentlayer/generated': path.resolve( + __dirname, + '../.contentlayer/generated', + ), } } return config diff --git a/frontend/apps/service-site/package.json b/frontend/apps/service-site/package.json index 2d1cf0586..f97131fc1 100644 --- a/frontend/apps/service-site/package.json +++ b/frontend/apps/service-site/package.json @@ -8,7 +8,7 @@ "dev:css": "tcm src --watch", "dev:storybook": "storybook dev -h localhost -p 6006 --no-open", "build": "pnpm gen && next build", - "build:storybook": "storybook build", + "build:storybook": "pnpm gen && storybook build", "gen": "conc -c auto pnpm:gen:*", "gen:css": "tcm src", "gen:contentlayer": "contentlayer2 build", diff --git a/frontend/apps/service-site/src/app/page.tsx b/frontend/apps/service-site/src/app/page.tsx index 95220301e..b844e3af3 100644 --- a/frontend/apps/service-site/src/app/page.tsx +++ b/frontend/apps/service-site/src/app/page.tsx @@ -1,6 +1,5 @@ -import { fallbackLang } from '@/features/i18n' import { TopPage } from '@/features/top' export default function Page() { - return + return } diff --git a/frontend/apps/service-site/src/app/posts/[slug]/page.tsx b/frontend/apps/service-site/src/app/posts/[slug]/page.tsx index 1f3053108..58855f3e4 100644 --- a/frontend/apps/service-site/src/app/posts/[slug]/page.tsx +++ b/frontend/apps/service-site/src/app/posts/[slug]/page.tsx @@ -24,5 +24,5 @@ const paramsSchema = object({ export default function Page({ params }: PageProps) { const { slug } = parse(paramsSchema, params) - return + return } diff --git a/frontend/apps/service-site/src/app/posts/page.tsx b/frontend/apps/service-site/src/app/posts/page.tsx index c98ef0e22..f5674ce4f 100644 --- a/frontend/apps/service-site/src/app/posts/page.tsx +++ b/frontend/apps/service-site/src/app/posts/page.tsx @@ -1,6 +1,5 @@ -import { fallbackLang } from '@/features/i18n' import { PostListPage } from '@/features/posts' export default function Page() { - return + return } diff --git a/frontend/apps/service-site/src/components/TopCards/TopCards.tsx b/frontend/apps/service-site/src/components/TopCards/TopCards.tsx index 382063a13..9ccc0073c 100644 --- a/frontend/apps/service-site/src/components/TopCards/TopCards.tsx +++ b/frontend/apps/service-site/src/components/TopCards/TopCards.tsx @@ -1,11 +1,12 @@ import type { Lang } from '@/features/i18n' +import { createPostDetailLink } from '@/features/posts' import type { Post } from 'contentlayer/generated' import Image from 'next/image' import Link from 'next/link' import styles from './TopCards.module.css' interface TopCardsProps { posts: Post[] - lang?: Lang + lang?: Lang | undefined } export function TopCards({ posts, lang }: TopCardsProps) { @@ -13,7 +14,7 @@ export function TopCards({ posts, lang }: TopCardsProps) {
{posts.map((post) => (
diff --git a/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.module.css b/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.module.css new file mode 100644 index 000000000..b247ab472 --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.module.css @@ -0,0 +1,55 @@ +.wrapper { + display: grid; + grid-auto-flow: row; + gap: var(--spacing-2); +} + +.label { + color: var(--global-body-text); + font-family: var(--main-font); + font-size: var(--font-size-2); + transition: color var(--default-hover-animation-duration) + var(--default-timing-function); +} + +.wrapper:hover .label { + color: var(--global-foreground); +} + +.titleWrapper { + display: grid; + grid-auto-flow: column; + gap: var(--spacing-half); + justify-content: space-between; +} + +.icon { + width: 1.25rem; + height: 1.25rem; + color: var(--global-foreground); + transition: color var(--default-hover-animation-duration) + var(--default-timing-function); +} + +.wrapper:hover .icon { + color: var(--primary-color); +} + +.title { + display: -webkit-box; + overflow: hidden; + color: var(--global-foreground); + text-overflow: ellipsis; + font-family: var(--message-font); + font-size: var(--font-size-7); + line-height: 120%; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + transition: color var(--default-hover-animation-duration) + var(--default-timing-function); +} + +.wrapper:hover .title { + color: var(--primary-color); +} diff --git a/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.stories.tsx b/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.stories.tsx new file mode 100644 index 000000000..e7ba071ab --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { aPost } from '../../factories' +import { NavNextPost } from './' + +const meta = { + component: NavNextPost, + args: { + post: aPost(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.tsx b/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.tsx new file mode 100644 index 000000000..95334f8f8 --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavNextPost/NavNextPost.tsx @@ -0,0 +1,27 @@ +import type { Lang } from '@/features/i18n' +import type { Post } from 'contentlayer/generated' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import type { FC } from 'react' +import { createPostDetailLink } from '../../utils' +import styles from './NavNextPost.module.css' + +type Props = { + lang?: Lang | undefined + post: Post +} + +export const NavNextPost: FC = ({ lang, post }) => { + return ( + + Next +
+ {post.title} + +
+ + ) +} diff --git a/frontend/apps/service-site/src/features/posts/components/NavNextPost/index.ts b/frontend/apps/service-site/src/features/posts/components/NavNextPost/index.ts new file mode 100644 index 000000000..f97bfc9f4 --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavNextPost/index.ts @@ -0,0 +1 @@ +export * from './NavNextPost' diff --git a/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.module.css b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.module.css new file mode 100644 index 000000000..b070bcf68 --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.module.css @@ -0,0 +1,56 @@ +.wrapper { + display: grid; + grid-auto-flow: row; + gap: var(--spacing-2); +} + +.label { + padding-left: var(--spacing-6); + color: var(--global-body-text); + font-family: var(--main-font); + font-size: var(--font-size-2); + transition: color var(--default-hover-animation-duration) + var(--default-timing-function); +} + +.wrapper:hover .label { + color: var(--global-foreground); +} + +.titleWrapper { + display: grid; + grid-auto-flow: column; + gap: var(--spacing-half); + justify-content: flex-start; +} + +.icon { + width: 1.25rem; + height: 1.25rem; + color: var(--global-foreground); + transition: color var(--default-hover-animation-duration) + var(--default-timing-function); +} + +.wrapper:hover .icon { + color: var(--primary-color); +} + +.title { + display: -webkit-box; + overflow: hidden; + color: var(--global-foreground); + text-overflow: ellipsis; + font-family: var(--message-font); + font-size: var(--font-size-7); + line-height: 120%; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + transition: color var(--default-hover-animation-duration) + var(--default-timing-function); +} + +.wrapper:hover .title { + color: var(--primary-color); +} diff --git a/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.stories.tsx b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.stories.tsx new file mode 100644 index 000000000..eee1639ef --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { aPost } from '../../factories' +import { NavPreviousPost } from './' + +const meta = { + component: NavPreviousPost, + args: { + post: aPost(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.tsx b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.tsx new file mode 100644 index 000000000..b3c03be3f --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/NavPreviousPost.tsx @@ -0,0 +1,27 @@ +import type { Lang } from '@/features/i18n' +import type { Post } from 'contentlayer/generated' +import { ChevronLeft } from 'lucide-react' +import Link from 'next/link' +import type { FC } from 'react' +import { createPostDetailLink } from '../../utils' +import styles from './NavPreviousPost.module.css' + +type Props = { + lang?: Lang | undefined + post: Post +} + +export const NavPreviousPost: FC = ({ lang, post }) => { + return ( + + Previous +
+ + {post.title} +
+ + ) +} diff --git a/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/index.ts b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/index.ts new file mode 100644 index 000000000..828626345 --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/NavPreviousPost/index.ts @@ -0,0 +1 @@ +export * from './NavPreviousPost' diff --git a/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.module.css b/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.module.css new file mode 100644 index 000000000..c29f1a100 --- /dev/null +++ b/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.module.css @@ -0,0 +1,29 @@ +.navPostWrapper { + display: grid; + grid-template-rows: auto auto; + gap: var(--spacing-8); +} + +.navPrev { + grid-area: 0 / 2; +} + +.navNext { + grid-area: 0 / 2; +} + +@media screen and (min-width: 768px) { + .navPostWrapper { + grid-template-columns: 1fr 1fr; + grid-template-rows: none; + gap: var(--spacing-10); + } + + .navPrev { + grid-area: 0 / 1; + } + + .navNext { + grid-area: 1 / 2; + } +} diff --git a/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.tsx b/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.tsx index 92282070e..e902c7520 100644 --- a/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.tsx +++ b/frontend/apps/service-site/src/features/posts/components/PostDetailPage/PostDetailPage.tsx @@ -1,23 +1,29 @@ -import type { Lang } from '@/features/i18n' +import { type Lang, fallbackLang } from '@/features/i18n' import { LinkHeading } from '@/features/posts/components/LinkHeading' import { PostHero } from '@/features/posts/components/PostHero' import { MDXContent } from '@/libs/contentlayer' import { notFound } from 'next/navigation' import type { FC } from 'react' -import { findPostByLangAndSlug } from '../../utils' +import { findPostByLangAndSlug, getNextPost, getPrevPost } from '../../utils' +import { NavNextPost } from '../NavNextPost' +import { NavPreviousPost } from '../NavPreviousPost' import { TableOfContents } from '../TableOfContents' +import styles from './PostDetailPage.module.css' const TOC_TARGET_CLASS_NAME = 'target-toc' type Props = { - lang: Lang + lang?: Lang slug: string } export const PostDetailPage: FC = ({ lang, slug }) => { - const post = findPostByLangAndSlug({ lang, slug }) + const post = findPostByLangAndSlug({ lang: lang ?? fallbackLang, slug }) if (!post) notFound() + const prevPost = getPrevPost({ lang: lang ?? fallbackLang, targetPost: post }) + const nextPost = getNextPost({ lang: lang ?? fallbackLang, targetPost: post }) + return (
@@ -25,6 +31,18 @@ export const PostDetailPage: FC = ({ lang, slug }) => { {/* FIXME: Add href props after implementing categories single page */} Categories +
+ {prevPost && ( +
+ +
+ )} + {nextPost && ( +
+ +
+ )} +
) } diff --git a/frontend/apps/service-site/src/features/posts/components/PostListPage/PostListPage.tsx b/frontend/apps/service-site/src/features/posts/components/PostListPage/PostListPage.tsx index ac69fc90a..65bef6c9a 100644 --- a/frontend/apps/service-site/src/features/posts/components/PostListPage/PostListPage.tsx +++ b/frontend/apps/service-site/src/features/posts/components/PostListPage/PostListPage.tsx @@ -1,16 +1,22 @@ -import { type Lang, getTranslation } from '@/features/i18n' +import { type Lang, fallbackLang, getTranslation } from '@/features/i18n' import { MDXContent } from '@/libs/contentlayer' import type { Post } from 'contentlayer/generated' -import { compareDesc, format, parseISO } from 'date-fns' +import { format, parseISO } from 'date-fns' import Link from 'next/link' import type { FC } from 'react' -import { filterPostsByLang } from '../../utils' +import { + createPostDetailLink, + filterPostsByLang, + sortPostsByDate, +} from '../../utils' -function PostCard(post: Post) { +function PostCard(post: Post, lang?: Lang) { return (

- {post.title} + + {post.title} +