Merge pull request #7 from acid-info/topic-implemnt-article
Implement Article page
This commit is contained in:
commit
823b0c10b2
|
@ -35,3 +35,5 @@ yarn-error.log*
|
|||
next-env.d.ts
|
||||
|
||||
.idea
|
||||
|
||||
.env
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
.relatedArticles > div > button {
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
/* temporary breakpoint */
|
||||
@media (min-width: 1024px) {
|
||||
.mobileToc {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,274 @@
|
|||
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: { aspectRatio = PostImageRatio.LANDSCAPE } = {},
|
||||
data: {
|
||||
coverImage = null,
|
||||
date: dateStr = '',
|
||||
title,
|
||||
blocks,
|
||||
summary,
|
||||
author,
|
||||
authorEmail,
|
||||
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])
|
||||
|
||||
// TODO : using typography for the blocks
|
||||
const _blocks = useMemo(
|
||||
() => <Blocks dangerouslySetInnerHTML={{ __html: blocks ?? '' }} />,
|
||||
[blocks],
|
||||
)
|
||||
|
||||
const _mobileToc = useMemo(
|
||||
() =>
|
||||
toc?.length > 0 && (
|
||||
<Collapse className={styles.mobileToc} label="Contents">
|
||||
{/* @ts-ignore */}
|
||||
{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>
|
||||
|
||||
<Title variant={'h1'} genericFontFamily="serif">
|
||||
{title}
|
||||
</Title>
|
||||
|
||||
{_thumbnail}
|
||||
|
||||
<CustomTypography variant={'body1'} genericFontFamily="sans-serif">
|
||||
{summary}
|
||||
</CustomTypography>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<TagContainer>
|
||||
{tags.map((tag) => (
|
||||
<Tag size="small" disabled={false} key={tag}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</TagContainer>
|
||||
)}
|
||||
|
||||
<AuthorInfo>
|
||||
<Typography
|
||||
variant="body3"
|
||||
component="p"
|
||||
genericFontFamily="sans-serif"
|
||||
>
|
||||
{author}
|
||||
</Typography>
|
||||
<Typography
|
||||
href={`mailto:${authorEmail}`}
|
||||
variant="body3"
|
||||
component="a"
|
||||
genericFontFamily="sans-serif"
|
||||
>
|
||||
{authorEmail}
|
||||
</Typography>
|
||||
</AuthorInfo>
|
||||
|
||||
{_mobileToc}
|
||||
|
||||
{_blocks}
|
||||
|
||||
{_references}
|
||||
|
||||
<ArticleReferences>
|
||||
{_moreFromAuthor}
|
||||
{_relatedArticles}
|
||||
</ArticleReferences>
|
||||
</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 CustomTypography = styled(Typography)`
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
const Title = styled(CustomTypography)`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
const Blocks = styled.div`
|
||||
white-space: pre-wrap;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 80px;
|
||||
`
|
||||
|
||||
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 TagContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
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;
|
||||
`
|
||||
|
||||
const ArticleReferences = styled.div`
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
const AuthorInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
`
|
|
@ -0,0 +1 @@
|
|||
export { default as Article } from './Article'
|
|
@ -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(),
|
||||
},
|
||||
]
|
|
@ -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;
|
||||
}
|
||||
`
|
|
@ -0,0 +1 @@
|
|||
export { default as ArticleReference } from './ArticleReference'
|
|
@ -0,0 +1,8 @@
|
|||
.collapse > div > button {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.collapse > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as Collapse } from './Collapse'
|
|
@ -3,6 +3,7 @@ import { IconButton, Typography } from '@acid-info/lsd-react'
|
|||
import { LogosIcon } from '../Icons/LogosIcon'
|
||||
import { SunIcon } from '../Icons/SunIcon'
|
||||
import { MoonIcon } from '../Icons/MoonIcon'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
interface NavbarProps {
|
||||
isDark: boolean
|
||||
|
@ -10,9 +11,10 @@ interface NavbarProps {
|
|||
}
|
||||
|
||||
export default function Navbar({ isDark, toggle }: NavbarProps) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Container>
|
||||
<LogosIconContainer>
|
||||
<LogosIconContainer onClick={() => router.push('/')}>
|
||||
<LogosIcon color="primary" />
|
||||
</LogosIconContainer>
|
||||
<Icons>
|
||||
|
@ -31,19 +33,34 @@ const Container = styled.nav`
|
|||
display: flex;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgb(var(--lsd-theme-primary));
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: calc(100% - 16px);
|
||||
background: rgb(var(--lsd-surface-primary));
|
||||
z-index: 100;
|
||||
|
||||
// to center-align logo
|
||||
&:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// to center-align logo
|
||||
&:before {
|
||||
content: 'D';
|
||||
margin: 1px auto 1px 1px;
|
||||
visibility: hidden;
|
||||
padding: 1px;
|
||||
}
|
||||
`
|
||||
|
||||
const LogosIconContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-left: unset;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,11 @@ import styled from '@emotion/styled'
|
|||
import Image from 'next/image'
|
||||
import { LogosCircleIcon } from '../Icons/LogosCircleIcon'
|
||||
import { useMemo } from 'react'
|
||||
import { UnbodyImageBlock } from '@/lib/unbody/unbody.types'
|
||||
import {
|
||||
UnbodyGoogleDoc,
|
||||
UnbodyImageBlock,
|
||||
UnbodyTextBlock,
|
||||
} from '@/lib/unbody/unbody.types'
|
||||
|
||||
export enum PostImageRatio {
|
||||
PORTRAIT = 'portrait',
|
||||
|
@ -46,8 +50,12 @@ export type PostDataProps = {
|
|||
title: string
|
||||
description?: string
|
||||
author?: string
|
||||
authorEmail?: string // TODO: can we get author: { name: string, email: string }?
|
||||
tags?: string[]
|
||||
coverImage?: UnbodyImageBlock
|
||||
coverImage?: UnbodyImageBlock | null
|
||||
summary?: string
|
||||
blocks?: UnbodyTextBlock
|
||||
toc?: Pick<UnbodyGoogleDoc, 'toc'>['toc']
|
||||
}
|
||||
|
||||
export const PostImageRatioOptions = {
|
||||
|
@ -71,7 +79,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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
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'
|
||||
import { UnbodyGoogleDoc } from '@/lib/unbody/unbody.types'
|
||||
|
||||
export type TableOfContentsProps = Pick<UnbodyGoogleDoc, 'toc'>
|
||||
|
||||
type Props = {
|
||||
contents?: TableOfContentsProps['toc']
|
||||
}
|
||||
|
||||
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) => {
|
||||
//@ts-ignore
|
||||
const section = document.getElementById(contents[index].href.substring(1))
|
||||
section?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
})
|
||||
setTocIndex(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
dy={dy}
|
||||
height={height}
|
||||
ref={stickyRef}
|
||||
{...props}
|
||||
className={sticky ? 'sticky' : ''}
|
||||
>
|
||||
<Title variant="body3">Contents</Title>
|
||||
{/* @ts-ignore */}
|
||||
{contents?.map((content, index) => (
|
||||
<Section
|
||||
active={index === tocIndex}
|
||||
onClick={() => handleSectionClick(index)}
|
||||
key={index}
|
||||
>
|
||||
<Typography variant="body3" genericFontFamily="sans-serif">
|
||||
{content.title}
|
||||
</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;
|
||||
`
|
|
@ -0,0 +1 @@
|
|||
export { default as TableOfContents } from './TableOfContents'
|
|
@ -1,3 +1,4 @@
|
|||
export const uiConfigs = {
|
||||
navbarRenderedHeight: 45,
|
||||
postMarginTop: 78,
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
.header > nav {
|
||||
border-bottom: none;
|
||||
}
|
|
@ -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} />
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,67 @@
|
|||
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,
|
||||
//@ts-ignore
|
||||
blocks: post.blocks
|
||||
?.map((block) => `${block.html}\n`)
|
||||
.slice(2)
|
||||
.join(''), // temporary solution for HTML/CSS work
|
||||
author: 'Cameron Williamson',
|
||||
authorEmail: 'leo@acid.info',
|
||||
tags: ['Tools', 'Cyber Punk', 'Docs'],
|
||||
//@ts-ignore
|
||||
toc: JSON.parse(post?.toc),
|
||||
...(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
|
|
@ -0,0 +1,29 @@
|
|||
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
|
||||
toc
|
||||
blocks{
|
||||
...on ImageBlock{
|
||||
url
|
||||
alt
|
||||
}
|
||||
... on TextBlock {
|
||||
footnotes
|
||||
html
|
||||
}
|
||||
}
|
||||
`)
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue