Merge pull request #7 from acid-info/topic-implemnt-article

Implement Article page
This commit is contained in:
amir houieh 2023-05-08 13:45:57 +02:00 committed by GitHub
commit 823b0c10b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 720 additions and 25 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,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;
`

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

@ -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;
}

View File

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

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,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;
`

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

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

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

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