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