From 052379afce3b70dff1e3a580526d788f9aa094cb Mon Sep 17 00:00:00 2001 From: jinhojang6 Date: Mon, 8 May 2023 09:12:38 +0900 Subject: [PATCH] feat: implement article page --- .gitignore | 2 + package.json | 1 + src/components/Article/Article.module.css | 10 + src/components/Article/Article.tsx | 250 ++++++++++++++++++ src/components/Article/index.ts | 1 + src/components/Article/tempData/index.tsx | 45 ++++ .../ArticleReference/ArticleReference.tsx | 51 ++++ src/components/ArticleReference/index.ts | 1 + src/components/Collapse/Collapse.module.css | 8 + src/components/Collapse/Collapse.tsx | 21 ++ src/components/Collapse/index.ts | 1 + src/components/Post/Post.tsx | 14 +- .../PostContainer/PostContainer.tsx | 8 +- src/components/Searchbar/Searchbar.tsx | 2 +- .../TableOfContents/TableOfContents.tsx | 82 ++++++ src/components/TableOfContents/index.ts | 1 + src/configs/ui.configs.ts | 1 + src/containers/.placeholder | 0 src/containers/ArticleContainer.Context.ts | 12 + src/containers/ArticleContainer.tsx | 46 ++++ .../ArticleLayout/Article.layout.module.css | 3 + src/layouts/ArticleLayout/Article.layout.tsx | 3 +- src/pages/article/[:slug].tsx | 15 -- src/pages/article/[slug].tsx | 66 +++++ src/queries/getPost.ts | 25 ++ src/services/unbody.service.ts | 7 + 26 files changed, 655 insertions(+), 21 deletions(-) create mode 100644 src/components/Article/Article.module.css create mode 100644 src/components/Article/Article.tsx create mode 100644 src/components/Article/index.ts create mode 100644 src/components/Article/tempData/index.tsx create mode 100644 src/components/ArticleReference/ArticleReference.tsx create mode 100644 src/components/ArticleReference/index.ts create mode 100644 src/components/Collapse/Collapse.module.css create mode 100644 src/components/Collapse/Collapse.tsx create mode 100644 src/components/Collapse/index.ts create mode 100644 src/components/TableOfContents/TableOfContents.tsx create mode 100644 src/components/TableOfContents/index.ts delete mode 100644 src/containers/.placeholder create mode 100644 src/containers/ArticleContainer.Context.ts create mode 100644 src/containers/ArticleContainer.tsx create mode 100644 src/layouts/ArticleLayout/Article.layout.module.css delete mode 100644 src/pages/article/[:slug].tsx create mode 100644 src/pages/article/[slug].tsx create mode 100644 src/queries/getPost.ts diff --git a/.gitignore b/.gitignore index fb30cbd..0bc659e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ yarn-error.log* next-env.d.ts .idea + +.env diff --git a/package.json b/package.json index 81eb900..c43a1a2 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/react": "18.0.35", "@types/react-dom": "18.0.11", "axios": "^1.4.0", + "clsx": "^1.2.1", "eslint": "8.38.0", "eslint-config-next": "13.3.0", "graphql": "^16.6.0", diff --git a/src/components/Article/Article.module.css b/src/components/Article/Article.module.css new file mode 100644 index 0000000..d0bf48e --- /dev/null +++ b/src/components/Article/Article.module.css @@ -0,0 +1,10 @@ +.relatedArticles > div > button { + border-top: none !important; +} + +/* temporary breakpoint */ +@media (min-width: 1024px) { + .mobileToc { + display: none !important; + } +} diff --git a/src/components/Article/Article.tsx b/src/components/Article/Article.tsx new file mode 100644 index 0000000..f2a8ced --- /dev/null +++ b/src/components/Article/Article.tsx @@ -0,0 +1,250 @@ +import { Tag, Typography } from '@acid-info/lsd-react' +import styled from '@emotion/styled' +import Image from 'next/image' +import { useMemo } from 'react' +import { PostProps } from '../Post' +import { PostImageRatio, PostImageRatioOptions, PostSize } from '../Post/Post' +import styles from './Article.module.css' +import { Collapse } from '@/components/Collapse' +import { useArticleContainerContext } from '@/containers/ArticleContainer.Context' +import { moreFromAuthor, references, relatedArticles } from './tempData' +import { ArticleReference } from '../ArticleReference' + +export default function Article({ + appearance: { + size = PostSize.SMALL, + aspectRatio = PostImageRatio.LANDSCAPE, + } = {}, + data: { + coverImage = null, + date: dateStr = '', + title, + text, + summary, + author, + tags = [], + toc = [], + }, + ...props +}: PostProps) { + const articleContainer = useArticleContainerContext() + const { tocIndex, setTocIndex } = articleContainer + + const date = new Date(dateStr) + + const _thumbnail = useMemo(() => { + if (!coverImage) return null + + return ( + + + + ) + }, [coverImage]) + + const _text = useMemo( + () => ( + + {text} + + ), + [text], + ) + + const _mobileToc = useMemo( + () => + toc?.length > 0 && ( + + {toc.map((toc, idx) => ( + setTocIndex(idx)} + active={idx === tocIndex} + variant="body3" + key={idx} + > + {toc} + + ))} + + ), + [toc, tocIndex], + ) + + const _references = useMemo( + () => + references?.length > 0 && ( + + {references.map((reference, idx) => ( + + + {idx + 1}. + + + {reference.text} + + + ))} + + ), + [references], + ) + + const _moreFromAuthor = useMemo( + () => + moreFromAuthor?.length > 0 && ( + + {moreFromAuthor.map((article, idx) => ( + + ))} + + ), + [moreFromAuthor], + ) + + const _relatedArticles = useMemo( + () => + relatedArticles?.length > 0 && ( + + {relatedArticles.map((article, idx) => ( + + ))} + + ), + [relatedArticles], + ) + + return ( + +
+ + + 10 minutes read + + + + {date.toLocaleString('en-GB', { + day: 'numeric', + month: 'long', // TODO: Should be uppercase + year: 'numeric', + })} + + +
+ + + {title} + + + {_thumbnail} + + + {summary} + + + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} + + + {author} + + + {_mobileToc} + + {_text} + + {_references} + +
+ {_moreFromAuthor} + {_relatedArticles} +
+
+ ) +} + +const ArticleContainer = styled.article` + display: flex; + position: relative; + flex-direction: column; + gap: 16px; + max-width: 700px; + margin-inline: 5%; + padding-bottom: 50px; + + // temporary breakpoint + @media (max-width: 1024px) { + margin-inline: 16px; + } +` + +const ThumbnailContainer = styled.div<{ + aspectRatio: PostImageRatio +}>` + aspect-ratio: ${(p) => + p.aspectRatio + ? PostImageRatioOptions[p.aspectRatio] + : PostImageRatioOptions[PostImageRatio.PORTRAIT]}; + position: relative; + width: 100%; + height: 100%; + max-height: 458px; // temporary max-height based on the Figma design's max height +` + +const Thumbnail = styled(Image)` + object-fit: cover; +` + +const Row = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-bottom: 8px; +` + +const CustomTypography = styled(Typography)` + text-overflow: ellipsis; + word-break: break-word; + white-space: pre-wrap; +` +const TagContainer = styled.div` + display: flex; + gap: 8px; + overflow-x: auto; +` + +const Content = styled(CustomTypography)<{ active: boolean }>` + padding: 8px 14px; + background-color: ${(p) => + p.active + ? 'rgb(var(--lsd-theme-primary))' + : 'rgb(var(--lsd-theme-secondary))'}; + color: ${(p) => + p.active + ? 'rgb(var(--lsd-theme-secondary))' + : 'rgb(var(--lsd-theme-primary))'}; +` + +const Reference = styled.div` + display: flex; + padding: 8px 14px; + gap: 8px; +` diff --git a/src/components/Article/index.ts b/src/components/Article/index.ts new file mode 100644 index 0000000..ad2cb33 --- /dev/null +++ b/src/components/Article/index.ts @@ -0,0 +1 @@ +export { default as Article } from './Article' diff --git a/src/components/Article/tempData/index.tsx b/src/components/Article/tempData/index.tsx new file mode 100644 index 0000000..758c1aa --- /dev/null +++ b/src/components/Article/tempData/index.tsx @@ -0,0 +1,45 @@ +import { ArticleReferenceType } from '@/components/ArticleReference/ArticleReference' + +// temporary type +export type ReferenceType = { + text: string + link: string +} + +// temporary data +export const references: ReferenceType[] = [ + { + text: 'Szto, Courtney, and Brian Wilson. "Reduce, re-use, re-ride: Bike waste and moving towards a circular economy for sporting goods." International Review for the Sociology of Sport (2022): 10126902221138033', + link: 'https://acid.info/', + }, + { + text: 'ohnson, Rebecca, Alice Kodama, and Regina Willensky. "The complete impact of bicycle use: analyzing the environmental impact and initiative of the bicycle industry." (2014).', + link: 'https://acid.info/', + }, +] + +export const moreFromAuthor: ArticleReferenceType[] = [ + { + title: 'How to Build a Practical Household Bike Generator', + author: 'Jason Freeman', + date: new Date(), + }, + { + title: 'Preventing an Orwellian Future with Privacy-Enhancing Technology', + author: 'Jason Freeman', + date: new Date(), + }, +] + +export const relatedArticles: ArticleReferenceType[] = [ + { + title: 'How to Build a Practical Household Bike Generator', + author: 'Jason Freeman', + date: new Date(), + }, + { + title: 'Preventing an Orwellian Future with Privacy-Enhancing Technology', + author: 'Jason Freeman', + date: new Date(), + }, +] diff --git a/src/components/ArticleReference/ArticleReference.tsx b/src/components/ArticleReference/ArticleReference.tsx new file mode 100644 index 0000000..ef024c7 --- /dev/null +++ b/src/components/ArticleReference/ArticleReference.tsx @@ -0,0 +1,51 @@ +import { Typography } from '@acid-info/lsd-react' +import styled from '@emotion/styled' + +export type ArticleReferenceType = { + title: string + author: string + date: Date +} + +type Props = { + data: ArticleReferenceType +} + +export default function ArticleReference({ + data: { title, author, date }, + ...props +}: Props) { + const localDate = date.toLocaleString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + + return ( + + + {title} + +
+ + {author} + + + + {localDate} + +
+
+ ) +} + +const Reference = styled.div` + display: flex; + flex-direction: column; + padding: 8px 14px; + border-bottom: 1px solid rgb(var(--lsd-border-primary)); + + &:last-child { + border-bottom: none; + } +` diff --git a/src/components/ArticleReference/index.ts b/src/components/ArticleReference/index.ts new file mode 100644 index 0000000..173e180 --- /dev/null +++ b/src/components/ArticleReference/index.ts @@ -0,0 +1 @@ +export { default as ArticleReference } from './ArticleReference' diff --git a/src/components/Collapse/Collapse.module.css b/src/components/Collapse/Collapse.module.css new file mode 100644 index 0000000..0e5d1b3 --- /dev/null +++ b/src/components/Collapse/Collapse.module.css @@ -0,0 +1,8 @@ +.collapse > div > button { + width: 100% !important; +} + +.collapse > div { + display: flex; + flex-direction: column; +} diff --git a/src/components/Collapse/Collapse.tsx b/src/components/Collapse/Collapse.tsx new file mode 100644 index 0000000..ea88df7 --- /dev/null +++ b/src/components/Collapse/Collapse.tsx @@ -0,0 +1,21 @@ +import { CollapseProps, Collapse as LsdCollapse } from '@acid-info/lsd-react' +import styles from './Collapse.module.css' +import styled from '@emotion/styled' +import clsx from 'clsx' + +export default function Collapse({ + label, + children, + className, + ...props +}: CollapseProps) { + return ( + + {children} + + ) +} diff --git a/src/components/Collapse/index.ts b/src/components/Collapse/index.ts new file mode 100644 index 0000000..d0f23d4 --- /dev/null +++ b/src/components/Collapse/index.ts @@ -0,0 +1 @@ +export { default as Collapse } from './Collapse' diff --git a/src/components/Post/Post.tsx b/src/components/Post/Post.tsx index 08e53b5..c7b2b1f 100644 --- a/src/components/Post/Post.tsx +++ b/src/components/Post/Post.tsx @@ -47,7 +47,10 @@ export type PostDataProps = { description?: string author?: string tags?: string[] - coverImage?: UnbodyImageBlock + coverImage?: UnbodyImageBlock | null + summary?: string + text?: string + toc?: string[] } export const PostImageRatioOptions = { @@ -71,7 +74,14 @@ export default function Post({ aspectRatio = PostImageRatio.LANDSCAPE, showImage = true, } = {}, - data: { coverImage, date: dateStr, title, description, author, tags = [] }, + data: { + coverImage = null, + date: dateStr = '', + title, + description, + author, + tags = [], + }, ...props }: PostProps) { const date = new Date(dateStr) diff --git a/src/components/PostContainer/PostContainer.tsx b/src/components/PostContainer/PostContainer.tsx index fbe4aac..890b612 100644 --- a/src/components/PostContainer/PostContainer.tsx +++ b/src/components/PostContainer/PostContainer.tsx @@ -16,7 +16,11 @@ export default function PostContainer({ }: PostContainerProps) { return (
- {title && ({title})} + {title && ( + + {title} + + )} {postsData.map((post, index) => ( @@ -34,7 +38,7 @@ const Container = styled.div` padding: 16px; gap: 24px; - // temporariy breakpoint + // temporary breakpoint @media (max-width: 768px) { flex-direction: column; } diff --git a/src/components/Searchbar/Searchbar.tsx b/src/components/Searchbar/Searchbar.tsx index 9983b0a..b5d9115 100644 --- a/src/components/Searchbar/Searchbar.tsx +++ b/src/components/Searchbar/Searchbar.tsx @@ -1,6 +1,5 @@ import { TextField, - Autocomplete, IconButton, SearchIcon, CloseIcon, @@ -15,6 +14,7 @@ import styled from '@emotion/styled' export type SearchbarProps = { searchScope?: ESearchScope + className?: string } export default function Searchbar(props: SearchbarProps) { diff --git a/src/components/TableOfContents/TableOfContents.tsx b/src/components/TableOfContents/TableOfContents.tsx new file mode 100644 index 0000000..567baab --- /dev/null +++ b/src/components/TableOfContents/TableOfContents.tsx @@ -0,0 +1,82 @@ +import { uiConfigs } from '@/configs/ui.configs' +import { useArticleContainerContext } from '@/containers/ArticleContainer.Context' +import { useSticky } from '@/utils/ui.utils' +import { Typography } from '@acid-info/lsd-react' +import styled from '@emotion/styled' + +type Props = { + contents: string[] +} + +export default function TableOfContents({ contents, ...props }: Props) { + const articleContainer = useArticleContainerContext() + const { tocIndex, setTocIndex } = articleContainer + const dy = uiConfigs.navbarRenderedHeight + uiConfigs.postMarginTop + + const { sticky, stickyRef, height } = useSticky(dy) + + const handleSectionClick = (index: number) => { + setTocIndex(index) + // TODO: scrollIntoView + } + + return ( + + Contents + {contents.map((content, index) => ( +
handleSectionClick(index)} + key={index} + > + + {content} + +
+ ))} +
+ ) +} + +const Container = styled.aside<{ dy: number; height: number }>` + display: flex; + flex-wrap: wrap; + flex-direction: column; + width: 162px; + box-sizing: border-box; + height: fit-content; + position: sticky; + top: ${(p) => `${p.dy}px`}; + margin-left: 16px; + + &.sticky { + top: ${uiConfigs.navbarRenderedHeight + 78 + 1}px; + z-index: 100; + height: ${(p) => `${p.height}px`}; + } + + // temporary breakpoint + @media (max-width: 1024px) { + display: none; + } +` + +const Title = styled(Typography)` + margin-bottom: 24px; +` + +const Section = styled.section<{ active: boolean }>` + display: flex; + padding: 8px 0 8px 12px; + border-left: ${(p) => + p.active + ? '1px solid rgb(var(--lsd-border-primary))' + : '1px solid transparent'}; + cursor: pointer; +` diff --git a/src/components/TableOfContents/index.ts b/src/components/TableOfContents/index.ts new file mode 100644 index 0000000..40ac94a --- /dev/null +++ b/src/components/TableOfContents/index.ts @@ -0,0 +1 @@ +export { default as TableOfContents } from './TableOfContents' diff --git a/src/configs/ui.configs.ts b/src/configs/ui.configs.ts index 1036bc5..1075dee 100644 --- a/src/configs/ui.configs.ts +++ b/src/configs/ui.configs.ts @@ -1,3 +1,4 @@ export const uiConfigs = { navbarRenderedHeight: 45, + postMarginTop: 78, } diff --git a/src/containers/.placeholder b/src/containers/.placeholder deleted file mode 100644 index e69de29..0000000 diff --git a/src/containers/ArticleContainer.Context.ts b/src/containers/ArticleContainer.Context.ts new file mode 100644 index 0000000..db3dff3 --- /dev/null +++ b/src/containers/ArticleContainer.Context.ts @@ -0,0 +1,12 @@ +import React from 'react' + +export type ArticleContainerContextType = { + tocIndex: number + setTocIndex: React.Dispatch> +} + +export const ArticleContainerContext = + React.createContext(null as any) + +export const useArticleContainerContext = () => + React.useContext(ArticleContainerContext) diff --git a/src/containers/ArticleContainer.tsx b/src/containers/ArticleContainer.tsx new file mode 100644 index 0000000..ecfdc05 --- /dev/null +++ b/src/containers/ArticleContainer.tsx @@ -0,0 +1,46 @@ +import { Article } from '@/components/Article' +import { TableOfContents } from '@/components/TableOfContents' +import { ArticleProps } from '@/pages/article/[slug]' +import styled from '@emotion/styled' +import { useState } from 'react' +import { uiConfigs } from '@/configs/ui.configs' +import { ArticleContainerContext } from '@/containers/ArticleContainer.Context' + +const ArticleContainer = (props: ArticleProps) => { + const { post } = props + const [tocIndex, setTocIndex] = useState(0) + + return ( + + {typeof post !== 'undefined' ? ( + + +
+ + + ) : ( +
+

Loading

+
+ )} + + ) +} + +const Container = styled.div` + display: flex; + justify-content: center; + margin-top: ${uiConfigs.postMarginTop}px; +` + +const Right = styled.aside` + width: 162px; + // temporary breakpoint + @media (max-width: 1024px) { + display: none; + } +` + +export default ArticleContainer diff --git a/src/layouts/ArticleLayout/Article.layout.module.css b/src/layouts/ArticleLayout/Article.layout.module.css new file mode 100644 index 0000000..2156f5a --- /dev/null +++ b/src/layouts/ArticleLayout/Article.layout.module.css @@ -0,0 +1,3 @@ +.header > nav { + border-bottom: none; +} diff --git a/src/layouts/ArticleLayout/Article.layout.tsx b/src/layouts/ArticleLayout/Article.layout.tsx index 0479971..130fc2a 100644 --- a/src/layouts/ArticleLayout/Article.layout.tsx +++ b/src/layouts/ArticleLayout/Article.layout.tsx @@ -4,12 +4,13 @@ import { PropsWithChildren } from 'react' import { NavbarFiller } from '@/components/Navbar/NavbarFiller' import { Searchbar } from '@/components/Searchbar' import { ESearchScope } from '@/types/ui.types' +import styles from './Article.layout.module.css' export default function ArticleLayout(props: PropsWithChildren) { const isDarkState = useIsDarkState() return ( <> -
+
diff --git a/src/pages/article/[:slug].tsx b/src/pages/article/[:slug].tsx deleted file mode 100644 index f820bd8..0000000 --- a/src/pages/article/[:slug].tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { NextPage } from 'next' -import { ArticleLayout } from '@/layouts/ArticleLayout' -import { ReactNode } from 'react' - -type Props = NextPage<{}> - -const ArticlePage = (props: Props) => { - return
article
-} - -ArticlePage.getLayout = function getLayout(page: ReactNode) { - return {page} -} - -export default ArticlePage diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx new file mode 100644 index 0000000..6dceef8 --- /dev/null +++ b/src/pages/article/[slug].tsx @@ -0,0 +1,66 @@ +import { GetStaticPropsContext } from 'next' +import { ArticleLayout } from '@/layouts/ArticleLayout' +import { ReactNode } from 'react' +import ArticleContainer from '@/containers/ArticleContainer' +import { UnbodyGoogleDoc, UnbodyImageBlock } from '@/lib/unbody/unbody.types' +import { getArticlePost } from '@/services/unbody.service' +import { PostDataProps } from '@/components/Post/Post' + +export type ArticleProps = { + post: PostDataProps + error: string | null +} + +export const getStaticProps = async ({ params }: GetStaticPropsContext) => { + const slug = params?.slug + console.log('slug', slug) // TODO : fetch data based on slug + let post: Partial = {} + let error = null + + try { + const posts = await getArticlePost() + post = posts[0] + } catch (e) { + error = JSON.stringify(e) + } + + return { + props: { + post: { + date: post.modifiedAt, + title: post.title, + summary: post.summary, + text: post.text, + author: 'Jinho', + tags: post.tags, + toc: [ + 'The dangers of totalitarian surveillance', + 'Orwellian Future', + 'Privacy-enhancing technology and its benefits', + 'Ethical considerations of privacy-enhancing technology', + ], + ...(post.blocks && post.blocks!.length > 0 + ? { coverImage: post.blocks![0] as UnbodyImageBlock } + : {}), + }, + error, + }, + } +} + +const ArticlePage = (props: ArticleProps) => { + return +} + +export async function getStaticPaths() { + return { + paths: [{ params: { slug: 'sth' } }], + fallback: true, + } +} + +ArticlePage.getLayout = function getLayout(page: ReactNode) { + return {page} +} + +export default ArticlePage diff --git a/src/queries/getPost.ts b/src/queries/getPost.ts new file mode 100644 index 0000000..67eeb4c --- /dev/null +++ b/src/queries/getPost.ts @@ -0,0 +1,25 @@ +import { GetGoogleDocQuery } from '.' +import { UnbodyExploreArgs } from '@/lib/unbody/unbody.types' + +const defaultArgs: UnbodyExploreArgs = { + limit: 1, + nearText: { concepts: ['home'] }, +} + +export const getArticlePostQuery = (args: UnbodyExploreArgs = defaultArgs) => + GetGoogleDocQuery(args)(` + sourceId + remoteId + title + summary + tags + createdAt + modifiedAt + text + blocks{ + ...on ImageBlock{ + url + alt + } + } + `) diff --git a/src/services/unbody.service.ts b/src/services/unbody.service.ts index bd7938f..567e324 100644 --- a/src/services/unbody.service.ts +++ b/src/services/unbody.service.ts @@ -3,6 +3,7 @@ import { UnbodyGoogleDoc, UnbodyGraphQlResponseGoogleDoc, } from '@/lib/unbody/unbody.types' +import { getArticlePostQuery } from '@/queries/getPost' import { getHomePagePostsQuery } from '@/queries/getPosts' const { UNBODY_API_KEY, UNBODY_LPE_PROJECT_ID } = process.env @@ -23,4 +24,10 @@ export const getHomepagePosts = (): Promise => { .then(({ data }) => data.Get.GoogleDoc) } +export const getArticlePost = (): Promise => { + return unbody + .request(getArticlePostQuery()) + .then(({ data }) => data.Get.GoogleDoc) +} + export default unbody