Merge pull request #11 from acid-info/search-view

Implement Search view (WIP)
This commit is contained in:
amir houieh 2023-05-11 09:53:56 +02:00 committed by GitHub
commit 4e7518eb1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 624 additions and 284 deletions

View File

@ -0,0 +1,68 @@
import { UnbodyImageBlock, UnbodyTextBlock } from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
import { ArticleImageBlockWrapper } from './Article.ImageBlockWrapper'
import { PostImageRatio } from '../Post/Post'
import styled from '@emotion/styled'
import { Typography } from '@acid-info/lsd-react'
import {
extractClassFromFirstTag,
extractIdFromFirstTag,
extractInnerHtml,
} from '@/utils/html.utils'
export const RenderArticleBlock = ({
block,
}: {
block: UnbodyImageBlock | UnbodyTextBlock
}) => {
switch (block.__typename) {
case UnbodyGraphQl.UnbodyDocumentTypeNames.ImageBlock:
return (
<ArticleImageBlockWrapper
ratio={PostImageRatio.LANDSCAPE}
image={block}
/>
)
case UnbodyGraphQl.UnbodyDocumentTypeNames.TextBlock:
switch (block.tagName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
return (
<Headline
variant={block.tagName as any}
component={block.tagName as any}
genericFontFamily="sans-serif"
className={extractClassFromFirstTag(block.html) || ''}
id={extractIdFromFirstTag(block.html) || ''}
dangerouslySetInnerHTML={{ __html: extractInnerHtml(block.html) }}
/>
)
default:
return (
<Paragraph
variant="body1"
component="p"
genericFontFamily="sans-serif"
className={extractClassFromFirstTag(block.html) || ''}
id={extractIdFromFirstTag(block.html) || ''}
dangerouslySetInnerHTML={{ __html: extractInnerHtml(block.html) }}
/>
)
}
default:
return null
}
}
const Headline = styled(Typography)`
white-space: pre-wrap;
margin-top: 24px;
`
const Paragraph = styled(Typography)`
white-space: pre-wrap;
`

View File

@ -0,0 +1,35 @@
import { UnbodyImageBlock } from '@/lib/unbody/unbody.types'
import styled from '@emotion/styled'
import React from 'react'
import { PostImageRatio, PostImageRatioOptions } from '../Post/Post'
import Image from 'next/image'
type Props = {
image: UnbodyImageBlock
ratio: PostImageRatio
}
export const ArticleImageBlockWrapper = ({ image, ratio }: Props) => {
return (
<ThumbnailContainer aspectRatio={ratio}>
<Thumbnail fill src={image.url} alt={image.alt} />
</ThumbnailContainer>
)
}
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;
`

View File

@ -8,13 +8,25 @@ import { Collapse } from '@/components/Collapse'
import { useArticleContainerContext } from '@/containers/ArticleContainer.Context'
import { moreFromAuthor, references, relatedArticles } from './tempData'
import { ArticleReference } from '../ArticleReference'
import { UnbodyGoogleDoc, UnbodyImageBlock } from '@/lib/unbody/unbody.types'
import {
UnbodyGoogleDoc,
UnbodyImageBlock,
UnbodyTextBlock,
UnbodyTocItem,
} from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
import { RenderArticleBlock } from './Article.Block'
import { ArticleImageBlockWrapper } from './Article.ImageBlockWrapper'
import { getArticleCover, getContentBlocks } from '@/utils/data.utils'
interface Props {
data: UnbodyGoogleDoc
data: UnbodyGoogleDoc & {
toc: UnbodyTocItem[]
}
}
export default function Article({ data }: Props) {
export default function ArticleBody({ data }: Props) {
const { title, summary, blocks, toc, createdAt, tags } = data
const articleContainer = useArticleContainerContext()
const { tocIndex, setTocIndex } = articleContainer
@ -26,60 +38,26 @@ export default function Article({ data }: Props) {
const date = new Date(createdAt)
const _thumbnail = useMemo(() => {
const imageBlocks: UnbodyImageBlock[] = blocks.filter(
(block): block is UnbodyImageBlock => block !== null && 'url' in block,
)
const coverImage = imageBlocks.reduce((prev, curr) =>
prev.order < curr.order ? prev : curr,
)
const coverImage = getArticleCover(blocks)
if (!coverImage) return null
return (
<ThumbnailContainer aspectRatio={PostImageRatio.LANDSCAPE}>
<Thumbnail fill src={coverImage.url} alt={coverImage.alt} />
</ThumbnailContainer>
<ArticleImageBlockWrapper
ratio={PostImageRatio.LANDSCAPE}
image={coverImage}
/>
)
}, [blocks])
const _blocks = useMemo(() => {
// Exclude title, subtitle, coverImage
const articleBlocks = blocks.sort((a, b) => a.order - b.order).slice(3)
return articleBlocks.map((block, idx) => {
return 'url' in block ? (
<ThumbnailContainer
key={'block-' + idx}
aspectRatio={PostImageRatio.LANDSCAPE}
>
<Thumbnail fill src={block.url} alt={block.alt} />
</ThumbnailContainer>
) : block.tagName.startsWith('h') ? (
<Headline
variant="body2"
component={block.tagName as any}
genericFontFamily="sans-serif"
key={'block-' + idx}
dangerouslySetInnerHTML={{ __html: block.html }}
/>
) : (
<Paragraph
variant="body1"
component="p"
genericFontFamily="sans-serif"
key={'block-' + idx}
dangerouslySetInnerHTML={{ __html: block.html }}
/>
)
})
return getContentBlocks(blocks).map((block, idx) => (
<RenderArticleBlock key={'block-' + idx} block={block} />
))
}, [blocks])
const _mobileToc = useMemo(
() =>
toc?.length > 0 && (
<Collapse className={styles.mobileToc} label="Contents">
{/* @ts-ignore */}
{toc.map((toc, idx) => (
<Content
onClick={() => setTocIndex(idx)}
@ -87,7 +65,7 @@ export default function Article({ data }: Props) {
variant="body3"
key={idx}
>
{toc}
{toc.title}
</Content>
))}
</Collapse>
@ -161,11 +139,9 @@ export default function Article({ data }: Props) {
</Row>
</div>
{/* assign id for toc scroll */}
<Title
/*
// @ts-ignore */
// @ts-ignore */
id={toc[0].href.substring(1)}
variant={'h1'}
genericFontFamily="serif"
@ -259,32 +235,6 @@ const TextContainer = styled.div`
margin-bottom: 80px;
`
const Headline = styled(Typography)`
white-space: pre-wrap;
margin-top: 24px;
`
const Paragraph = styled(Typography)`
white-space: pre-wrap;
`
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;

View File

@ -0,0 +1,34 @@
import Link from 'next/link'
import { Grid, GridItem } from '../Grid/Grid'
import styled from '@emotion/styled'
import Post, { PostDataProps } from '../Post/Post'
type Props = {
post: PostDataProps
}
const FeaturedPost = ({ post }: Props) => {
return (
<Grid>
<GridItem className="w-16">
<PostLink href={`/article/${post.remoteId}`}>
<PostWrapper>
<Post data={post} />
</PostWrapper>
</PostLink>
</GridItem>
</Grid>
)
}
const PostWrapper = styled.div`
padding: 16px 0;
border-top: 1px solid rgb(var(--lsd-theme-primary));
width: 100%;
`
const PostLink = styled(Link)`
text-decoration: none;
`
export default FeaturedPost

View File

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

View File

@ -0,0 +1,44 @@
import styled from '@emotion/styled'
export const Grid = styled.div`
display: grid;
grid-template-columns: repeat(16, 1fr);
padding: 16px;
gap: 16px;
@media (max-width: 768px) {
grid-template-columns: 100%;
}
`
export const GridItem = styled.div`
grid-column: span 4;
&.w-1 {
grid-column: span 1;
}
&.w-2 {
grid-column: span 2;
}
&.w-3 {
grid-column: span 3;
}
&.w-4 {
grid-column: span 4;
}
&.w-8 {
grid-column: span 8;
}
&.w-16 {
grid-column: span 16;
}
@media (max-width: 768px) {
grid-column: span 16 !important;
}
`

View File

@ -0,0 +1,13 @@
import styled from '@emotion/styled'
import { uiConfigs } from '@/configs/ui.configs'
import { PropsWithChildren } from 'react'
const Main = ({ children }: PropsWithChildren) => {
return <Container>{children}</Container>
}
const Container = styled.main`
margin-block: ${uiConfigs.postSectionMargin}px;
`
export default Main

View File

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

View File

@ -1,43 +0,0 @@
import { useState } from 'react'
import { PostContainer } from '../PostContainer'
import { PostDataProps, PostProps } from './Post'
type Props = {
posts: PostDataProps[]
featuredPost: PostDataProps
}
const PostsDemo = (props: Props) => {
const [posts, setPosts] = useState<PostDataProps[]>(props.posts)
return (
<div style={{ marginBlock: '78px' }}>
{/* For Demo purposes only. Use inline CSS and styled components temporarily */}
{/*@TODO @jinho, wht PostContainer should recive an array of postData instead of only One?*/}
<PostContainer
title="Featured"
postsData={[
{
data: props.featuredPost,
},
]}
/>
{posts.length > 0 ? (
<PostContainer
style={{ marginTop: '108px' }}
title="Latest Posts"
postsData={posts.map((post) => ({
appearance: {},
data: post,
}))}
/>
) : (
<div style={{ marginTop: '108px', textAlign: 'center' }}>
<h3>No Posts found!</h3>
</div>
)}
</div>
)
}
export default PostsDemo

View File

@ -1,3 +1,2 @@
export { default as Post } from './Post'
export { default as PostsDemo } from './PostsDemo'
export type { PostProps } from './Post'

View File

@ -1,62 +0,0 @@
import { CommonProps } from '@acid-info/lsd-react/dist/utils/useCommonProps'
import styled from '@emotion/styled'
import { Post, PostProps } from '../Post'
import { Typography } from '@acid-info/lsd-react'
import Link from 'next/link'
export type PostContainerProps = CommonProps &
React.HTMLAttributes<HTMLDivElement> & {
title?: string
postsData: PostProps[]
}
export default function PostContainer({
title,
postsData,
...props
}: PostContainerProps) {
return (
<div {...props}>
{title && (
<Title variant="body1" genericFontFamily="sans-serif">
{title}
</Title>
)}
<Container>
{postsData.map((post, index) => (
<PostLink key={index} href={`/article/${post.data.remoteId}`}>
<PostWrapper>
<Post {...post} />
</PostWrapper>
</PostLink>
))}
</Container>
</div>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
padding: 16px;
gap: 24px;
// temporary breakpoint
@media (max-width: 768px) {
flex-direction: column;
}
`
const PostWrapper = styled.div`
padding: 16px 0;
border-top: 1px solid rgb(var(--lsd-theme-primary));
width: 100%;
`
const Title = styled(Typography)`
padding: 0 16px;
`
const PostLink = styled(Link)`
text-decoration: none;
`

View File

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

View File

@ -0,0 +1,39 @@
import Link from 'next/link'
import { useState } from 'react'
import { Grid, GridItem } from '../Grid/Grid'
import styled from '@emotion/styled'
import Post, { PostDataProps } from '../Post/Post'
type Props = {
posts: PostDataProps[]
}
export const PostsList = (props: Props) => {
const [posts, setPosts] = useState<PostDataProps[]>(props.posts)
//TODO pagination
return (
<Grid>
{posts.map((post, index) => (
<GridItem className="w-4" key={index}>
<PostLink href={`/article/${post.remoteId}`}>
<PostWrapper>
<Post data={post} />
</PostWrapper>
</PostLink>
</GridItem>
))}
</Grid>
)
}
const PostWrapper = styled.div`
padding: 16px 0;
border-top: 1px solid rgb(var(--lsd-theme-primary));
width: 100%;
`
const PostLink = styled(Link)`
text-decoration: none;
`

View File

@ -0,0 +1,36 @@
import { Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import { PropsWithChildren } from 'react'
type Props = PropsWithChildren<{
title: string
matches?: number
}>
export const Section = ({ title, matches, children, ...props }: Props) => {
return (
<section {...props}>
<Container>
<Typography genericFontFamily="sans-serif" variant="body2">
{title}
</Typography>
{matches && (
<>
<Typography variant="body2"></Typography>
<Typography genericFontFamily="sans-serif" variant="body2">
{matches} matches
</Typography>
</>
)}
</Container>
{children}
</section>
)
}
const Container = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
`

View File

@ -14,7 +14,7 @@ type Props = {
export default function TableOfContents({ contents, ...props }: Props) {
const articleContainer = useArticleContainerContext()
const { tocIndex, setTocIndex } = articleContainer
const dy = uiConfigs.navbarRenderedHeight + uiConfigs.postMarginTop
const dy = uiConfigs.navbarRenderedHeight + uiConfigs.postSectionMargin
const { sticky, stickyRef, height } = useSticky<HTMLDivElement>(dy)
@ -86,7 +86,8 @@ const Contents = styled.div<{ height: number }>`
flex-direction: column;
overflow-y: auto;
height: calc(
100vh - ${uiConfigs.navbarRenderedHeight + uiConfigs.postMarginTop + 40}px
100vh -
${uiConfigs.navbarRenderedHeight + uiConfigs.postSectionMargin + 40}px
);
&::-webkit-scrollbar {

View File

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

View File

@ -1,39 +1,37 @@
import { Article } from '@/components/Article'
import { TableOfContents } from '@/components/TableOfContents'
import styled from '@emotion/styled'
import { useState } from 'react'
import { uiConfigs } from '@/configs/ui.configs'
import { ArticleContainerContext } from '@/containers/ArticleContainer.Context'
import { UnbodyGoogleDoc } from '@/lib/unbody/unbody.types'
import { UnbodyGoogleDoc, UnbodyTocItem } from '@/lib/unbody/unbody.types'
import ArticleBody from '@/components/Article/ArticleBody'
interface Props {
data: UnbodyGoogleDoc
error: string | null
data: UnbodyGoogleDoc & {
toc: UnbodyTocItem[]
}
}
const ArticleContainer = (props: Props) => {
const { data, error } = props
const { data } = props
const [tocIndex, setTocIndex] = useState(0)
return !error?.length ? (
return (
<Container>
<ArticleContainerContext.Provider
value={{ tocIndex: tocIndex, setTocIndex: setTocIndex }}
>
<TableOfContents contents={data.toc ?? []} />
<Article data={data} />
<ArticleBody data={data} />
<Right />
</ArticleContainerContext.Provider>
</Container>
) : (
<div>{error}</div>
)
}
const Container = styled.div`
display: flex;
justify-content: center;
margin-top: ${uiConfigs.postMarginTop}px;
`
const Right = styled.aside`

View File

@ -6,6 +6,7 @@ import { Searchbar } from '@/components/Searchbar'
import { ESearchScope } from '@/types/ui.types'
import styles from './Article.layout.module.css'
import { Footer } from '@/components/Footer'
import { Main } from '@/components/Main'
export default function ArticleLayout(props: PropsWithChildren<any>) {
const isDarkState = useIsDarkState()
@ -16,7 +17,7 @@ export default function ArticleLayout(props: PropsWithChildren<any>) {
<NavbarFiller />
<Searchbar searchScope={ESearchScope.ARTICLE} />
</header>
<main>{props.children}</main>
<Main>{props.children}</Main>
<Footer />
</>
)

View File

@ -5,6 +5,7 @@ import { Hero } from '@/components/Hero'
import { NavbarFiller } from '@/components/Navbar/NavbarFiller'
import { Searchbar } from '@/components/Searchbar'
import { Footer } from '@/components/Footer'
import { Main } from '@/components/Main'
export default function DefaultLayout(props: PropsWithChildren<any>) {
const isDarkState = useIsDarkState()
@ -17,7 +18,7 @@ export default function DefaultLayout(props: PropsWithChildren<any>) {
<Hero />
<Searchbar />
</header>
<main>{props.children}</main>
<Main>{props.children}</Main>
<Footer />
</>
)

View File

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

View File

@ -0,0 +1,24 @@
import { Navbar } from '@/components/Navbar'
import useIsDarkState from '@/states/isDarkState/isDarkState'
import { PropsWithChildren } from 'react'
import { NavbarFiller } from '@/components/Navbar/NavbarFiller'
import { Searchbar } from '@/components/Searchbar'
import { ESearchScope } from '@/types/ui.types'
import styles from './Search.layout.module.css'
import { Footer } from '@/components/Footer'
import { Main } from '@/components/Main'
export default function SearchLayout(props: PropsWithChildren<any>) {
const isDarkState = useIsDarkState()
return (
<>
<header className={styles.header}>
<Navbar isDark={isDarkState.get()} toggle={isDarkState.toggle} />
<NavbarFiller />
<Searchbar searchScope={ESearchScope.ARTICLE} />
</header>
<Main>{props.children}</Main>
<Footer />
</>
)
}

View File

@ -0,0 +1 @@
export { default as SearchLayout } from './Search.layout'

View File

@ -93,8 +93,13 @@ export namespace UnbodyGraphQl {
}
export namespace Fragments {
export interface TocItem {}
export interface TocItem {
tag: string
blockIndex: number
href: string
title: string
level: number
}
export interface FootnoteItem {}
}
@ -152,7 +157,7 @@ export namespace UnbodyGraphQl {
modifiedAt: string
originalName: string
path: string[]
pathstring: string
pathString: string
remoteId: string
size: number
sourceId: string
@ -298,7 +303,7 @@ export namespace UnbodyGraphQl {
export interface WhereOperandsInpObj {
operator?: WhereOperatorEnum
path: string[]
path: string[] | string
operands?: WhereOperandsInpObj[]
valueGeoRange?: WhereGeoRangeInpObj
valueNumber?: number
@ -330,7 +335,7 @@ export namespace UnbodyGraphQl {
}
export interface WhereInpObj {
path?: string[]
path?: string[] | string
valueInt?: number
valueNumber?: number
valueGeoRange?: WhereGeoRangeInpObj

View File

@ -6,6 +6,7 @@ export type UnbodyTextBlock = UnbodyGraphQl.TextBlock
export type UnbodyImageBlock = UnbodyGraphQl.ImageBlock
export type UnbodyAudio = UnbodyGraphQl.AudioFile
export type UnbodyGetFilters = UnbodyGraphQl.Filters.GetDocsArgs
export type UnbodyTocItem = UnbodyGraphQl.Fragments.TocItem
export * as UnbodyGraphQl from './unbody-content.types'

View File

@ -7,35 +7,16 @@ import { ArticlePostData } from '@/types/data.types'
import { SEO } from '@/components/SEO'
type ArticleProps = {
data: ArticlePostData | null
error: string | null
data: ArticlePostData
errors: string | null
}
export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
const { remoteId } = params!
if (!remoteId) {
return {
notFound: true,
}
}
const { data: article, errors } = await api.getArticlePost(remoteId as string)
return {
props: {
data: article,
error: errors,
},
}
}
// @jinho lets handle the error directly in thew page component
const ArticlePage = (props: ArticleProps) => {
if (!props.data) return <div style={{ height: '100vh' }} />
const ArticlePage = ({ data, errors }: ArticleProps) => {
if (errors) return <div>{errors}</div>
return (
<>
<SEO title={props.data.title} description={props.data.summary} />
<ArticleContainer data={props.data} error={props.error} />
<SEO title={data.title} description={data.summary} />
<ArticleContainer data={data} />
</>
)
}
@ -50,6 +31,30 @@ export async function getStaticPaths() {
}
}
export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
const { remoteId } = params!
if (!remoteId) {
return {
notFound: true,
}
}
const { data: article, errors } = await api.getArticlePost(remoteId as string)
if (!article) {
return {
notFound: true,
}
}
return {
props: {
data: article,
error: JSON.stringify(errors),
},
}
}
ArticlePage.getLayout = function getLayout(page: ReactNode) {
return <ArticleLayout>{page}</ArticleLayout>
}

View File

@ -1,38 +1,60 @@
import { PostDataProps } from '@/components/Post/Post'
import PostsDemo from '@/components/Post/PostsDemo'
import { UnbodyGoogleDoc, UnbodyImageBlock } from '@/lib/unbody/unbody.types'
import api from '@/services/unbody.service'
import { ESearchStatus } from '@/types/ui.types'
import { GetStaticProps } from 'next'
import { PostDataProps } from '@/components/Post/Post'
import { PostsList } from '@/components/PostList/PostList'
import { Section } from '@/components/Section/Section'
import { getArticleCover } from '@/utils/data.utils'
import { FeaturedPost } from '@/components/FeaturedPost'
type Props = {
posts: PostDataProps[]
featured: PostDataProps | null
error: string | null
}
export default function Home({ posts }: Props) {
export default function Home({ posts, featured }: Props) {
return (
<>
<PostsDemo posts={posts} featuredPost={posts[0]} />
{featured && (
<Section title={'Featured'}>
<FeaturedPost post={featured} />
</Section>
)}
<Section title={'Latest posts'}>
<PostsList posts={posts} />
</Section>
</>
)
}
export const getStaticProps = async () => {
const { data: posts, errors } = await api.getHomepagePosts()
const {
data: { posts, featured },
errors,
} = await api.getHomepagePosts()
return {
props: {
featured: featured
? {
remoteId: featured.remoteId,
date: featured.modifiedAt,
title: featured.title,
description: featured.subtitle, // TODO: summary is not available
author: 'Jinho',
tags: featured.tags,
coverImage: getArticleCover(featured.blocks),
}
: null,
posts: posts.map((post) => ({
remoteId: post.remoteId,
date: post.modifiedAt,
title: post.title,
description: post.summary,
description: post.subtitle, // TODO: summary is not available
author: 'Jinho',
tags: post.tags,
...(post.blocks && post.blocks!.length > 0
? { coverImage: post.blocks![0] as UnbodyImageBlock }
: {}),
coverImage: getArticleCover(post.blocks),
})),
errors,
},

View File

@ -21,8 +21,12 @@ import {
extractTopicsFromQuery,
} from '@/utils/search.utils'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { SearchLayout } from '@/layouts/SearchLayout'
import { Section } from '@/components/Section/Section'
import { PostsList } from '@/components/PostList/PostList'
import { getArticleCover } from '@/utils/data.utils'
interface SearchPageProps {
articles: SearchResultItem<UnbodyGoogleDoc>[]
@ -73,24 +77,22 @@ export default function SearchPage({
return (
<div>
<section>
<strong>Related articles</strong>
<hr />
<div>
{articles.loading && <div>...</div>}
{!articles.error && articles.data && articles.data.length > 0 ? (
articles.data.map((article: SearchResultItem<UnbodyGoogleDoc>) => (
<div key={article.doc.remoteId}>
<h2>{article.doc.title}</h2>
<p>{article.doc.summary}</p>
</div>
))
) : (
<div>Nothing found</div>
)}
</div>
</section>
<br />
{articles.data?.length && (
<Section title={'Related Articles'} matches={articles.data?.length}>
<PostsList
posts={articles.data.map((article) => ({
remoteId: article.doc.remoteId,
date: article.doc.modifiedAt,
title: article.doc.title,
description: article.doc.subtitle, // TODO: summary is not available
author: 'Jinho',
tags: article.doc.tags,
coverImage: getArticleCover(article.doc.blocks),
}))}
/>
</Section>
)}
<section>
<strong>Related content blocks</strong>
<hr />
@ -156,6 +158,10 @@ export default function SearchPage({
)
}
SearchPage.getLayout = function getLayout(page: ReactNode) {
return <SearchLayout>{page}</SearchLayout>
}
export async function getStaticProps() {
const { data: articles = [] } = await unbodyApi.searchArticles()
const { data: blocks = [] } = await unbodyApi.serachBlocks()

View File

@ -10,22 +10,26 @@ export const getArticlePostQuery = (args: UnbodyGetFilters = defaultArgs) =>
sourceId
remoteId
title
subtitle
summary
tags
createdAt
modifiedAt
toc
slug
blocks{
...on ImageBlock{
url
alt
order
__typename
}
... on TextBlock {
footnotes
html
order
tagName
__typename
}
}
`)

View File

@ -12,14 +12,18 @@ export const getHomePagePostsQuery = (args: UnbodyGetFilters = defaultArgs) =>
GetGoogleDocQuery(args)(`
remoteId
title
subtitle
summary
tags
createdAt
modifiedAt
pathString
blocks{
...on ImageBlock{
url
alt
order
__typename
}
}
`)

View File

@ -7,9 +7,18 @@ export const getSearchArticlesQuery = (args: UnbodyGetFilters = defaultArgs) =>
GetGoogleDocQuery(args)(`
remoteId
title
subtitle
summary
tags
modifiedAt
blocks{
...on ImageBlock{
url
alt
order
__typename
}
}
_additional{
certainty
}

View File

@ -6,10 +6,13 @@ import {
UnbodyImageBlock,
UnbodyTextBlock,
UnbodyGraphQlResponseBlocks,
UnbodyGraphQlResponse,
} from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
type WhereOperandsInpObj = UnbodyGraphQl.Filters.WhereOperandsInpObj
import { getArticlePostQuery } from '@/queries/getPost'
import { getHomePagePostsQuery } from '@/queries/getPosts'
import { getAllPostsSlugQuery } from '@/queries/getPostsSlugs'
@ -35,8 +38,41 @@ type HomepagePost = Pick<
| 'blocks'
>
type HomePageData = {
posts: HomepagePost[]
featured: HomepagePost | null
}
type UnbodyDocTypes = UnbodyGoogleDoc | UnbodyImageBlock | UnbodyTextBlock
const OperandFactory = (
operator: UnbodyGraphQl.Filters.WhereOperatorEnum,
path: string | string[],
value: string,
valuePath: string,
): WhereOperandsInpObj => ({
path,
operator,
[valuePath]: value,
})
export const Operands: Record<string, (...a: any) => WhereOperandsInpObj> = {
WHERE_PUBLISHED: () =>
OperandFactory(
UnbodyGraphQl.Filters.WhereOperatorEnum.Like,
'pathString',
'*/published/*',
'valueString',
),
WHERE_ID_IS: (id) =>
OperandFactory(
UnbodyGraphQl.Filters.WhereOperatorEnum.Equal,
'remoteId',
id,
'valueString',
),
}
const mapSearchResultItem = <T extends UnbodyDocTypes>(
q: string,
tags: string[],
@ -68,17 +104,33 @@ class UnbodyService extends UnbodyClient {
}
}
getHomepagePosts = (): Promise<ApiResponse<HomepagePost[]>> => {
return this.request<UnbodyGraphQlResponseGoogleDoc>(getHomePagePostsQuery())
getHomepagePosts = (): Promise<ApiResponse<HomePageData>> => {
const query = getHomePagePostsQuery({
where: Operands.WHERE_PUBLISHED(),
})
return this.request<UnbodyGraphQlResponseGoogleDoc>(query)
.then(({ data }) => {
if (!data) return this.handleResponse([], 'No data')
return this.handleResponse(data.Get.GoogleDoc)
if (!data)
return this.handleResponse({ featured: null, posts: [] }, 'No data')
const featured =
data.Get.GoogleDoc.find((post) =>
post.pathString.includes('/featured/'),
) || null
const posts = data.Get.GoogleDoc.filter(
(post) => !post.pathString.includes('/featured/'),
)
return this.handleResponse({ featured, posts })
})
.catch((e) => this.handleResponse([], e))
.catch((e) => this.handleResponse({ featured: null, posts: [] }, e))
}
getAllArticlePostSlugs = (): Promise<ApiResponse<{ remoteId: string }[]>> => {
return this.request<UnbodyGraphQlResponseGoogleDoc>(getAllPostsSlugQuery())
return this.request<UnbodyGraphQlResponseGoogleDoc>(
getAllPostsSlugQuery({
where: Operands.WHERE_PUBLISHED(),
}),
)
.then(({ data }) => {
if (!data) return this.handleResponse([], 'No data')
return this.handleResponse(data.Get.GoogleDoc)
@ -88,13 +140,15 @@ class UnbodyService extends UnbodyClient {
getArticlePost = (
id: string,
published: boolean = true,
): Promise<ApiResponse<UnbodyGoogleDoc | null>> => {
const query = getArticlePostQuery({
where: {
path: ['remoteId'],
operator: UnbodyGraphQl.Filters.WhereOperatorEnum.Equal,
valueString: id,
},
where: published
? {
operator: UnbodyGraphQl.Filters.WhereOperatorEnum.And,
operands: [Operands.WHERE_PUBLISHED(), Operands.WHERE_ID_IS(id)],
}
: Operands.WHERE_ID_IS(id),
})
return this.request<UnbodyGraphQlResponseGoogleDoc>(query)
@ -103,6 +157,7 @@ class UnbodyService extends UnbodyClient {
const article = data.Get.GoogleDoc[0]
return this.handleResponse({
...article,
blocks: article.blocks.sort((a, b) => a.order - b.order),
toc: JSON.parse(
article.toc as string,
) as Array<UnbodyGraphQl.Fragments.TocItem>,
@ -114,6 +169,7 @@ class UnbodyService extends UnbodyClient {
serachBlocks = async (
q: string = '',
tags: string[] = [],
published: boolean = true,
): Promise<
ApiResponse<SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[]>
> => {
@ -124,12 +180,14 @@ class UnbodyService extends UnbodyClient {
concepts: [q, ...tags],
certainty: 0.75,
},
...(published
? {
where: Operands.WHERE_PUBLISHED(),
}
: {}),
}
: {}),
})
console.log(query)
return this.request<UnbodyGraphQlResponseBlocks>(query)
.then(({ data }) => {
if (!data || !(data.Get.ImageBlock || data.Get.TextBlock))
@ -147,16 +205,20 @@ class UnbodyService extends UnbodyClient {
searchArticles = (
q: string = '',
tags: string[] = [],
published: boolean = true,
): Promise<ApiResponse<SearchResultItem<UnbodyGoogleDoc>[]>> => {
const query = getSearchArticlesQuery({
...(q.trim().length > 0
? {
nearText: {
concepts: [q],
},
}
: {}),
...((tags.length > 0 && {
let queryFilters = {}
if (q.trim().length > 0) {
queryFilters = {
nearText: {
concepts: [q],
},
}
}
if (tags.length > 0) {
queryFilters = {
...queryFilters,
where: {
operator: UnbodyGraphQl.Filters.WhereOperatorEnum.And,
operands: tags.map((tag) => ({
@ -165,9 +227,20 @@ class UnbodyService extends UnbodyClient {
valueString: tag,
})),
},
}) ||
{}),
})
}
}
if (published) {
queryFilters = {
...queryFilters,
where: {
...((queryFilters as any).where || {}),
...Operands.WHERE_PUBLISHED(),
},
}
}
const query = getSearchArticlesQuery(queryFilters)
return this.request<UnbodyGraphQlResponseGoogleDoc>(query)
.then(({ data }) => {

37
src/utils/data.utils.ts Normal file
View File

@ -0,0 +1,37 @@
import {
UnbodyGoogleDoc,
UnbodyImageBlock,
UnbodyTextBlock,
} from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
function hasClassName(inputString: string, className: string) {
const regex = new RegExp(`class\\s*=\\s*"[^"]*\\b${className}\\b[^"]*"`)
return regex.test(inputString)
}
export const getContentBlocks = (
blocks: (UnbodyImageBlock | UnbodyTextBlock)[],
) => {
return blocks.filter((b) => {
return (
(b.__typename === UnbodyGraphQl.UnbodyDocumentTypeNames.ImageBlock &&
b.order !== 4) ||
(b.__typename === UnbodyGraphQl.UnbodyDocumentTypeNames.TextBlock &&
!hasClassName(b.html, 'subtitle') &&
!hasClassName(b.html, 'title'))
)
})
}
export const getArticleCover = (
blocks: (UnbodyImageBlock | UnbodyTextBlock)[],
): UnbodyImageBlock | null => {
return (
(blocks.find(
(b) =>
b.order === 4 &&
b.__typename === UnbodyGraphQl.UnbodyDocumentTypeNames.ImageBlock,
) as UnbodyImageBlock) || null
)
}

31
src/utils/html.utils.ts Normal file
View File

@ -0,0 +1,31 @@
const regexForInnerHtml = /<[^>]*>/
// const regexForInnerHtml = /<[^>]*>([^<]*)<\/[^>]*>/
const regexForId = /id="([^"]*)"/
const regexForClass = /class="([^"]*)"/
//
// export const extractInnerHtml = (html: string) => {
// const match = html.match(regexForInnerHtml)
// return match ? match[1] : html
// }
export function extractInnerHtml(htmlString: string) {
var regex = /^<[^>]+>([\s\S]*)<\/[^>]+>$/
var match = regex.exec(htmlString)
if (match) {
return match[1]
} else {
return ''
}
}
export const extractIdFromFirstTag = (html: string) => {
const match = html.match(regexForId)
return match ? match[1] : null
}
export const extractClassFromFirstTag = (html: string) => {
const match = html.match(regexForClass)
return match ? match[1] : null
}