feat: implement article page

This commit is contained in:
jinhojang6 2023-05-08 09:12:38 +09:00
parent bebde816ef
commit 052379afce
No known key found for this signature in database
GPG Key ID: 0E7AA62CB0D9E6F3
26 changed files with 655 additions and 21 deletions

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ yarn-error.log*
next-env.d.ts
.idea
.env

View File

@ -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",

View File

@ -0,0 +1,10 @@
.relatedArticles > div > button {
border-top: none !important;
}
/* temporary breakpoint */
@media (min-width: 1024px) {
.mobileToc {
display: none !important;
}
}

View File

@ -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 (
<ThumbnailContainer aspectRatio={aspectRatio}>
<Thumbnail fill src={coverImage.url} alt={coverImage.alt} />
</ThumbnailContainer>
)
}, [coverImage])
const _text = useMemo(
() => (
<CustomTypography variant="body1" genericFontFamily="sans-serif">
{text}
</CustomTypography>
),
[text],
)
const _mobileToc = useMemo(
() =>
toc?.length > 0 && (
<Collapse className={styles.mobileToc} label="Contents">
{toc.map((toc, idx) => (
<Content
onClick={() => setTocIndex(idx)}
active={idx === tocIndex}
variant="body3"
key={idx}
>
{toc}
</Content>
))}
</Collapse>
),
[toc, tocIndex],
)
const _references = useMemo(
() =>
references?.length > 0 && (
<Collapse label="References">
{references.map((reference, idx) => (
<Reference key={idx}>
<Typography component="span" variant="body3">
{idx + 1}.
</Typography>
<Typography
component="a"
variant="body3"
href={reference.link}
target="_blank"
>
{reference.text}
</Typography>
</Reference>
))}
</Collapse>
),
[references],
)
const _moreFromAuthor = useMemo(
() =>
moreFromAuthor?.length > 0 && (
<Collapse label="More From The Author">
{moreFromAuthor.map((article, idx) => (
<ArticleReference key={idx} data={article} />
))}
</Collapse>
),
[moreFromAuthor],
)
const _relatedArticles = useMemo(
() =>
relatedArticles?.length > 0 && (
<Collapse className={styles.relatedArticles} label="Related Articles">
{relatedArticles.map((article, idx) => (
<ArticleReference key={idx} data={article} />
))}
</Collapse>
),
[relatedArticles],
)
return (
<ArticleContainer {...props}>
<div>
<Row>
<Typography variant="body3" genericFontFamily="sans-serif">
10 minutes read
</Typography>
<Typography variant="body3"></Typography>
<Typography variant="body3" genericFontFamily="sans-serif">
{date.toLocaleString('en-GB', {
day: 'numeric',
month: 'long', // TODO: Should be uppercase
year: 'numeric',
})}
</Typography>
</Row>
</div>
<CustomTypography
variant={size === PostSize.SMALL ? 'h4' : 'h2'}
genericFontFamily="serif"
>
{title}
</CustomTypography>
{_thumbnail}
<CustomTypography
variant={size === PostSize.SMALL ? 'h4' : 'h2'}
genericFontFamily="serif"
>
{summary}
</CustomTypography>
{tags.length > 0 && (
<TagContainer>
{tags.map((tag) => (
<Tag size="small" disabled={false} key={tag}>
{tag}
</Tag>
))}
</TagContainer>
)}
<Typography variant="body3" genericFontFamily="sans-serif">
{author}
</Typography>
{_mobileToc}
{_text}
{_references}
<div>
{_moreFromAuthor}
{_relatedArticles}
</div>
</ArticleContainer>
)
}
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;
`

View File

@ -0,0 +1 @@
export { default as Article } from './Article'

View File

@ -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(),
},
]

View File

@ -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 (
<Reference {...props}>
<Typography component="span" variant="body1">
{title}
</Typography>
<div>
<Typography variant="body3" genericFontFamily="sans-serif">
{author}
</Typography>
<Typography variant="body3"></Typography>
<Typography variant="body3" genericFontFamily="sans-serif">
{localDate}
</Typography>
</div>
</Reference>
)
}
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;
}
`

View File

@ -0,0 +1 @@
export { default as ArticleReference } from './ArticleReference'

View File

@ -0,0 +1,8 @@
.collapse > div > button {
width: 100% !important;
}
.collapse > div {
display: flex;
flex-direction: column;
}

View File

@ -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 (
<LsdCollapse
label={label}
{...props}
className={clsx(styles.collapse, className)}
>
{children}
</LsdCollapse>
)
}

View File

@ -0,0 +1 @@
export { default as Collapse } from './Collapse'

View File

@ -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)

View File

@ -16,7 +16,11 @@ export default function PostContainer({
}: PostContainerProps) {
return (
<div {...props}>
{title && (<Title variant="body1" genericFontFamily="sans-serif">{title}</Title>)}
{title && (
<Title variant="body1" genericFontFamily="sans-serif">
{title}
</Title>
)}
<Container>
{postsData.map((post, index) => (
<PostWrapper key={index}>
@ -34,7 +38,7 @@ const Container = styled.div`
padding: 16px;
gap: 24px;
// temporariy breakpoint
// temporary breakpoint
@media (max-width: 768px) {
flex-direction: column;
}

View File

@ -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) {

View File

@ -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<HTMLDivElement>(dy)
const handleSectionClick = (index: number) => {
setTocIndex(index)
// TODO: scrollIntoView
}
return (
<Container
dy={dy}
height={height}
ref={stickyRef}
{...props}
className={sticky ? 'sticky' : ''}
>
<Title variant="body3">Contents</Title>
{contents.map((content, index) => (
<Section
active={index === tocIndex}
onClick={() => handleSectionClick(index)}
key={index}
>
<Typography variant="body3" genericFontFamily="sans-serif">
{content}
</Typography>
</Section>
))}
</Container>
)
}
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;
`

View File

@ -0,0 +1 @@
export { default as TableOfContents } from './TableOfContents'

View File

@ -1,3 +1,4 @@
export const uiConfigs = {
navbarRenderedHeight: 45,
postMarginTop: 78,
}

View File

@ -0,0 +1,12 @@
import React from 'react'
export type ArticleContainerContextType = {
tocIndex: number
setTocIndex: React.Dispatch<React.SetStateAction<number>>
}
export const ArticleContainerContext =
React.createContext<ArticleContainerContextType>(null as any)
export const useArticleContainerContext = () =>
React.useContext(ArticleContainerContext)

View File

@ -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 (
<Container>
{typeof post !== 'undefined' ? (
<ArticleContainerContext.Provider
value={{ tocIndex: tocIndex, setTocIndex: setTocIndex }}
>
<TableOfContents contents={post.toc ?? []} />
<Article data={post} />
<Right />
</ArticleContainerContext.Provider>
) : (
<div style={{ marginTop: '108px', textAlign: 'center' }}>
<h3>Loading</h3>
</div>
)}
</Container>
)
}
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

View File

@ -0,0 +1,3 @@
.header > nav {
border-bottom: none;
}

View File

@ -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<any>) {
const isDarkState = useIsDarkState()
return (
<>
<header>
<header className={styles.header}>
<Navbar isDark={isDarkState.get()} toggle={isDarkState.toggle} />
<NavbarFiller />
<Searchbar searchScope={ESearchScope.ARTICLE} />

View File

@ -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>article</article>
}
ArticlePage.getLayout = function getLayout(page: ReactNode) {
return <ArticleLayout>{page}</ArticleLayout>
}
export default ArticlePage

View File

@ -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<UnbodyGoogleDoc> = {}
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 <ArticleContainer post={props.post} error={props.error} />
}
export async function getStaticPaths() {
return {
paths: [{ params: { slug: 'sth' } }],
fallback: true,
}
}
ArticlePage.getLayout = function getLayout(page: ReactNode) {
return <ArticleLayout>{page}</ArticleLayout>
}
export default ArticlePage

25
src/queries/getPost.ts Normal file
View File

@ -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
}
}
`)

View File

@ -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<HomepagePost[]> => {
.then(({ data }) => data.Get.GoogleDoc)
}
export const getArticlePost = (): Promise<HomepagePost[]> => {
return unbody
.request<UnbodyGraphQlResponseGoogleDoc>(getArticlePostQuery())
.then(({ data }) => data.Get.GoogleDoc)
}
export default unbody