Blog page (#15)

This commit is contained in:
Manuel Garcia Genta 2022-04-11 12:18:52 -03:00 committed by GitHub
parent 4e8b9d5a64
commit a5e0829ed4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 26544 additions and 51 deletions

43
codegen-fix.js Normal file
View File

@ -0,0 +1,43 @@
/* eslint-disable no-console */
const fs = require('fs')
const find1 = `
CreatedAtAsc = '_createdAt_ASC',
CreatedAtDesc = '_createdAt_DESC',
CreatedAtAsc = 'createdAt_ASC',
CreatedAtDesc = 'createdAt_DESC',`
const replace1 = `
CreatedAtAsc = 'createdAt_ASC',
CreatedAtDesc = 'createdAt_DESC',`
const find2 = `
UpdatedAtAsc = '_updatedAt_ASC',
UpdatedAtDesc = '_updatedAt_DESC',
UpdatedAtAsc = 'updatedAt_ASC',
UpdatedAtDesc = 'updatedAt_DESC',`
const replace2 = `
UpdatedAtAsc = 'updatedAt_ASC',
UpdatedAtDesc = 'updatedAt_DESC',`
const filePath = './src/lib/cms/generated.ts'
fs.readFile(filePath, 'utf8', function (err, data) {
if (err) {
return console.error(err)
}
let result = data
let found = true
while (found) {
found = result.includes(find1) || result.includes(find2)
result = result.replace(find1, replace1)
result = result.replace(find2, replace2)
}
fs.writeFile(filePath, result, 'utf8', function (err) {
if (err) return console.error(err)
else console.log('Codegen fixed successfully ✨')
})
})

22
gql-codegen.yml Normal file
View File

@ -0,0 +1,22 @@
overwrite: true
schema:
- 'https://graphql.datocms.com/':
headers:
Authorization: Bearer ${CMS_ACCESS_TOKEN}
documents: './src/**/*.{gql,graphql}'
generates:
./src/lib/cms/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-graphql-request'
config:
namingConvention:
enumValues: keep
./src/lib/cms/graphql.schema.json:
plugins:
- 'introspection'
hooks:
afterOneFileWrite:
- yarn lint --fix
- yarn codegen-fix

8306
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,27 +13,42 @@
"postbuild": "next-sitemap",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx && stylelint '**/*.{css,scss}'",
"tsc": "tsc --pretty --noEmit"
"tsc": "tsc --pretty --noEmit",
"gql-codegen": "graphql-codegen --config gql-codegen.yml -r dotenv/config",
"codegen-fix": "node codegen-fix"
},
"dependencies": {
"@graphql-codegen/introspection": "^2.1.1",
"@graphql-codegen/typescript-graphql-request": "^4.4.5",
"@graphql-codegen/typescript-operations": "^2.3.5",
"@juggle/resize-observer": "^3.3.1",
"@radix-ui/react-polymorphic": "^0.0.14",
"@types/lodash": "^4.14.181",
"@types/marked": "^4.0.3",
"clsx": "^1.1.1",
"datocms-structured-text-to-plain-text": "^2.0.4",
"graphql": "^16.3.0",
"graphql-request": "^4.2.0",
"gsap": "./src/lib/gsap/gsap-bonus.tgz",
"keen-slider": "^6.6.5",
"lodash": "^4.17.21",
"marked": "^4.0.13",
"next": "^12.1.4",
"next-real-viewport": "^0.7.0",
"next-seo": "^5.4.0",
"react": "^18.0.0",
"react-datocms": "^3.0.12",
"react-device-detect": "^2.1.2",
"react-dom": "^18.0.0",
"react-fast-marquee": "^1.3.1",
"react-hook-form": "7.29.0",
"react-merge-refs": "^1.1.0",
"react-query": "^3.34.19",
"react-use-measure": "^2.1.1",
"sharp": "0.30.3"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.6.2",
"@next/bundle-analyzer": "^12.1.4",
"@types/css-font-loading-module": "0.0.7",
"@types/node": "^17.0.23",

6
public/sitemap-0.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://laconic.com</loc><changefreq>daily</changefreq><priority>0.7</priority><lastmod>2022-04-05T18:49:08.426Z</lastmod></url>
<url><loc>https://laconic.com/blog</loc><changefreq>daily</changefreq><priority>0.7</priority><lastmod>2022-04-05T18:49:08.426Z</lastmod></url>
<url><loc>https://laconic.com/community</loc><changefreq>daily</changefreq><priority>0.7</priority><lastmod>2022-04-05T18:49:08.426Z</lastmod></url>
</urlset>

4
public/sitemap.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://laconic.com/sitemap-0.xml</loc></sitemap>
</sitemapindex>

View File

@ -6,6 +6,12 @@
width: 100%;
padding-bottom: tovw(2px, 'default', 2px);
&-blog {
.content {
height: auto;
}
}
&__header {
font-family: var(--font-dm-mono), sans-serif;
font-size: tovw(18px, 'default', 12px);
@ -16,6 +22,32 @@
letter-spacing: tovw(-0.6px, 'default', -0.6px);
gap: tovw(14px, 'default', 8px);
&-blog {
align-items: center;
display: flex;
flex-direction: row;
gap: tovw(8px, 'default', 8px);
.category {
align-items: center;
border: tovw(1px, 'default', 1px) solid var(--color-white);
border-radius: tovw(4px, 'default', 4px);
display: flex;
font-family: var(--font-dm-mono), sans-serif;
font-size: tovw(12px, 'default', 12px);
line-height: tovw(16px, 'default', 16px);
height: tovw(32px, 'default', 32px);
max-width: fit-content;
padding: 0 tovw(8px, 'default', 8px);
text-transform: uppercase;
}
.date {
font-family: var(--font-dm-mono), sans-serif;
margin-left: tovw(16px, 'default', 16px);
}
}
div {
display: flex;
color: var(--color-grey-light);
@ -47,6 +79,48 @@
color: var(--color-grey-light);
}
}
@media screen and (min-width: 801px) {
&.horizontal {
flex-direction: row;
gap: tovw(32px, 'default', 16px);
> .image__container {
height: tovw(355px, 'default', 200px);
margin: 0;
width: 50%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
> div {
width: 50%;
h2 {
font-size: tovw(40px, 'default', 40px);
line-height: 134%;
}
.content {
margin-top: tovw(16px, 'default', 16px);
p {
font-size: tovw(24px, 'default', 24px);
line-height: 134%;
letter-spacing: -0.01em;
}
}
.image__container {
display: none;
}
}
}
}
}
.image__container {
@ -73,6 +147,7 @@
flex-direction: column;
place-content: space-between;
gap: tovw(24px, 'default', 18px);
text-align: left;
a {
width: fit-content;

View File

@ -3,7 +3,11 @@ import clsx from 'clsx'
import { Calendar, Clock } from '~/components/icons/events'
import Heading from '~/components/primitives/heading'
import Link from '~/components/primitives/link'
import { BlogPostFragment } from '~/lib/cms/generated'
import { formatDate } from '~/lib/utils'
import { getDescription } from '~/lib/utils/blog'
import Category from '../category'
import s from './card.module.scss'
interface CardProps {
@ -52,3 +56,62 @@ const Card = ({ className, data, isNews = false }: CardProps) => {
}
export default Card
interface BlogCardProps {
className?: string
data?: BlogPostFragment
horizontal?: boolean
}
export const BlogCard = ({
className,
data,
horizontal = false
}: BlogCardProps) => {
return (
<div
className={clsx(
s['card'],
s['card-blog'],
className,
horizontal && s.horizontal
)}
>
{horizontal && data?.image && (
<div className={clsx(s['image__container'], 'hide-on-mobile')}>
<img
alt={data?.image?.alt ?? ''}
loading="lazy"
src={data?.image?.url}
/>
</div>
)}
<div>
<div className={s['card__header-blog']}>
{data?.category.map((category: any, index) => (
<span key={index}>
<Category label={category.title} />
</span>
))}
<span className={s.date}>{formatDate(data?.date)}</span>
</div>
{data?.image && (
<div className={s['image__container']}>
<img
alt={data?.image?.alt ?? ''}
loading="lazy"
src={data?.image?.url}
/>
</div>
)}
<div className={s['content']}>
<Heading as="h2" variant="sm" font="tthoves">
{data?.title}
</Heading>
{horizontal && <p>{data && getDescription(data)}</p>}
<Link href={`/blog/${data?.slug}`}>READ ARTICLE</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,16 @@
@import '~/css/helpers';
.category {
align-items: center;
border: tovw(1px, 'default', 1px) solid var(--color-white);
border-radius: tovw(4px, 'default', 4px);
display: flex;
font-family: var(--font-dm-mono), sans-serif;
font-size: tovw(12px, 'default', 12px);
line-height: tovw(16px, 'default', 16px);
height: tovw(32px, 'default', 32px);
max-width: fit-content;
padding: 0 tovw(8px, 'default', 8px);
text-transform: uppercase;
white-space: nowrap;
}

View File

@ -0,0 +1,14 @@
import clsx from 'clsx'
import s from './category.module.scss'
interface CategoryProps {
className?: string
label: string
}
const Category = ({ className, label }: CategoryProps) => {
return <span className={clsx(s.category, className)}>{label}</span>
}
export default Category

View File

@ -0,0 +1,27 @@
import clsx from 'clsx'
import { marked } from 'marked'
import { useMemo } from 'react'
import styles from './marked.module.scss'
type Props = {
children: string
className?: string
noDefaultStyles?: boolean
noTag?: boolean
}
const Marked = ({ children, className, noDefaultStyles }: Props) => {
const html = useMemo(() => {
return marked(children)
}, [children])
return (
<div
className={clsx(className, { [styles.markdown]: !noDefaultStyles })}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export default Marked

View File

@ -0,0 +1,21 @@
.markdown {
ul {
list-style: disc;
padding-left: 32px;
}
table {
@apply border border-solid border-gray-50 rounded-[48px] border-collapse w-full overflow-hidden;
appearance: none;
-webkit-appearance: none;
}
th {
@apply pt-8 pb-6 px-8 text-base text-center border-b border-r border-solid border-gray-50;
}
td {
@apply py-6 px-8 text-base border-b border-r border-solid border-gray-50;
}
}

View File

@ -0,0 +1,26 @@
@import '~/css/helpers';
.section {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
max-width: 100%;
min-height: calc(var(--vh) * 100);
text-align: center;
background: linear-gradient(
180deg,
rgb(0 0 244 / 0.9) 1.63%,
rgb(0 0 244 / 0) 99.89%
);
@media screen and (max-width: 800px) {
min-height: auto;
padding-top: tovw(204px, 'default', 204px);
}
.container {
padding-top: tovw(156px, 'default', 159px);
z-index: 10;
}
}

View File

@ -0,0 +1,26 @@
import { BlogCard } from '~/components/common/card'
import { Container } from '~/components/layout/container'
import Section from '~/components/layout/section'
import Heading from '~/components/primitives/heading'
import { BlogPostFragment } from '~/lib/cms/generated'
import s from './hero.module.scss'
interface HeroProps {
featuredPost: BlogPostFragment
}
const Hero = ({ featuredPost }: HeroProps) => {
return (
<Section className={s['section']}>
<Heading as="h1" variant="xl" centered>
Blog
</Heading>
<Container className={s['container']}>
<BlogCard data={featuredPost} horizontal />
</Container>
</Section>
)
}
export default Hero

View File

@ -0,0 +1,27 @@
@import '~/css/helpers';
$img-height: 590px;
$img-height-mobile: 200px;
.section {
transform: translateY(
calc(tovw($img-height, 'default', $img-height-mobile) * -1 / 2)
);
@media screen and (max-width: 800px) {
transform: none;
}
.image__container {
width: 100%;
height: tovw($img-height, 'default', $img-height-mobile);
border-top: tovw(1px, 'default', 1px) solid var(--color-white);
border-bottom: tovw(1px, 'default', 1px) solid var(--color-white);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}

View File

@ -0,0 +1,32 @@
import CustomStructuredText from 'lib/renderer'
import { Container } from '~/components/layout/container'
import Section from '~/components/layout/section'
import { BlogPostFragment } from '~/lib/cms/generated'
import s from './content.module.scss'
interface ContentProps {
data: BlogPostFragment
}
const Content = ({ data }: ContentProps) => {
return (
<Section className={s['section']}>
<Container className={s['container']}>
{data.image && (
<div className={s['image__container']}>
<img
alt={data.image?.alt ?? 'post image'}
loading="lazy"
src={data.image?.url}
/>
</div>
)}
</Container>
<CustomStructuredText data={data.content} />
</Section>
)
}
export default Content

View File

@ -0,0 +1,52 @@
@import '~/css/helpers';
.section {
overflow: visible;
position: relative;
display: flex;
align-items: center;
flex-direction: column;
max-width: 100%;
min-height: calc(var(--vh) * 100);
padding-top: tovw(220px, 'default', 150px);
text-align: center;
background: linear-gradient(
180deg,
rgb(0 0 244 / 0.9) 1.63%,
rgb(0 0 244 / 0) 99.89%
);
@media screen and (max-width: 800px) {
min-height: auto;
padding-bottom: tovw(66px, 'default', 66px);
}
.author {
font-size: tovw(18px, 'default', 18px);
font-family: var(--font-dm-mono);
line-height: tovw(22px, 'default', 22px);
letter-spacing: -0.02em;
margin-top: tovw(48px, 'default', 48px);
text-transform: uppercase;
}
.date {
font-size: tovw(18px, 'default', 18px);
font-family: var(--font-dm-mono);
line-height: tovw(22px, 'default', 22px);
letter-spacing: -0.02em;
margin-bottom: tovw(20px, 'default', 23px);
}
h1 {
max-width: tovw(900px, 'default', 900px);
}
.categories {
display: flex;
gap: tovw(8px, 'default', 8px);
list-style: none;
margin-top: tovw(45px, 'default', 45px);
padding: 0;
}
}

View File

@ -0,0 +1,36 @@
import clsx from 'clsx'
import Category from '~/components/common/category'
import Section from '~/components/layout/section'
import Heading from '~/components/primitives/heading'
import { BlogPostFragment } from '~/lib/cms/generated'
import { formatDate } from '~/lib/utils'
import s from './hero.module.scss'
interface HeroProps {
data: BlogPostFragment
}
const Hero = ({ data }: HeroProps) => {
return (
<Section className={s['section']}>
<span className={s.date}>{formatDate(data.date)}</span>
<Heading as="h1" variant="xl" centered>
{data.title}
</Heading>
<span className={clsx(s.author, 'hide-on-desktop')}>
By {data.author?.name}
</span>
<ul className={clsx(s.categories, 'hide-on-mobile')}>
{data.category.map((category, index) => (
<li key={index}>
<Category label={category.title ?? ''} />
</li>
))}
</ul>
</Section>
)
}
export default Hero

View File

@ -0,0 +1,18 @@
import { Container } from '~/components/layout/container'
import Section from '~/components/layout/section'
import s from './posts-grid.module.scss'
interface PostsGridProps {
children: React.ReactNode
}
const PostsGrid = ({ children }: PostsGridProps) => {
return (
<Section>
<Container className={s['container']}>{children}</Container>
</Section>
)
}
export default PostsGrid

View File

@ -0,0 +1,14 @@
@import '~/css/helpers';
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: tovw(24px, 'default', 24px);
row-gap: tovw(104px, 'default', 81px);
padding-bottom: tovw(80px, 'default', 80px);
padding-top: tovw(80px, 'default', 88px);
@media screen and (max-width: 800px) {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,25 @@
const SearchIcon = ({ className }: { className?: string }) => (
<svg
width={22}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...className}
>
<path
d="M9.313 16.625A7.312 7.312 0 1 0 9.313 2a7.312 7.312 0 0 0 0 14.625Z"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.375 14.375 20 20"
stroke="currentColor"
strokeWidth={2}
strokeLinejoin="round"
/>
</svg>
)
export default SearchIcon

View File

@ -0,0 +1,54 @@
import { ChangeEventHandler } from 'react'
import { Container } from '~/components/layout/container'
import Section from '~/components/layout/section'
import SearchIcon from './icon'
import s from './search.module.scss'
type Props = {
search: string | undefined
onChange: ChangeEventHandler<HTMLInputElement>
}
const SearchBar = ({ search, onChange }: Props) => {
return (
<div className={s.search}>
<SearchIcon />
<input
placeholder="Search"
onChange={onChange}
name="search"
type="text"
value={search ?? ''}
autoComplete="off"
/>
</div>
)
}
export default SearchBar
type ContainerProps = {
children: React.ReactNode
}
export const SearchContainer = ({ children }: ContainerProps) => {
return (
<Section>
<Container>
<div className={s.container}>{children}</div>
</Container>
</Section>
)
}
export const LoadMoreContainer = ({ children }: ContainerProps) => {
return (
<Section>
<Container>
<div className={s['load-more']}>{children}</div>
</Container>
</Section>
)
}

View File

@ -0,0 +1,72 @@
@import '~/css/helpers';
.container {
display: flex;
align-items: flex-end;
justify-content: space-between;
@media screen and (max-width: 800px) {
gap: tovw(32px, 'default', 32px);
flex-direction: column-reverse;
padding-top: tovw(104px, 'default', 104px);
}
> nav {
display: flex;
gap: 12px;
@media screen and (max-width: 800px) {
overflow-x: scroll;
margin-left: -40px;
margin-right: -40px;
}
a {
text-decoration: none;
white-space: nowrap;
}
}
}
.load-more {
padding-top: tovw(120px, 'default', 80px);
padding-bottom: tovw(34px, 'default', 120px);
text-align: center;
> button {
@media screen and (max-width: 800px) {
width: 100%;
}
}
}
.search {
position: relative;
@media screen and (max-width: 800px) {
width: 100%;
}
> svg {
position: absolute;
right: 0;
bottom: 12px;
z-index: -1;
}
> input {
font-family: var(--font-tt-hoves);
font-size: tovw(24px, 'default', 18px);
line-height: 1;
letter-spacing: -0.02em;
background: none;
border: none;
border-bottom: 1px solid white;
padding-bottom: 12px;
color: white;
@media screen and (max-width: 800px) {
width: 100%;
}
}
}

98
src/lib/blog.ts Normal file
View File

@ -0,0 +1,98 @@
import { flatten } from 'lodash'
import cms from './cms'
import { BlogPostRecord } from './cms/generated'
import { getPagination, PaginationData } from './utils'
const DEFAULT_PAGE_STEP = 9
export type GetBlogPostsParams = {
page?: number
step?: number
filterIds?: string[]
filterSlugs?: string[]
category?: string
search?: string
}
export const getBlogPosts = async ({
page = 1,
step = DEFAULT_PAGE_STEP,
filterIds,
filterSlugs,
category,
search = ''
}: GetBlogPostsParams) => {
const { allBlogPosts, _allBlogPostsMeta } = await cms().GetBlogPosts({
skip: (page - 1) * step,
step,
filterIds,
filterSlugs,
category,
search
})
const pagination = getPagination(page, _allBlogPostsMeta.count, step)
return {
pagination,
data: allBlogPosts
}
}
export const getBlogPostsCategories = async () => {
const { allCategories } = await cms().GetBlogPostsCategories()
// Push dummy category
allCategories.push({
id: '0',
slug: 'none',
title: 'No Category'
})
return allCategories
}
export type GetBlogPostsSlugsParams = {
page?: number
step?: number
}
export const getBlogPostsSlugs = async ({
page = 1,
step = DEFAULT_PAGE_STEP
}: GetBlogPostsSlugsParams) => {
const { allBlogPosts, _allBlogPostsMeta } = await cms().BlogPostsSlug({
skip: (page - 1) * step,
step
})
const pagination = getPagination(page, _allBlogPostsMeta.count, step)
return {
pagination,
data: allBlogPosts
}
}
export const getAllBlogPostsSlugs = async () => {
const postsChunks: Pick<BlogPostRecord, 'slug'>[][] = []
let nextPage: number | null = 1
while (nextPage) {
const {
pagination,
data
}: { pagination: PaginationData; data: Pick<BlogPostRecord, 'slug'>[] } =
await getBlogPostsSlugs({
page: nextPage,
step: 100
})
postsChunks.push(data)
nextPage = pagination.nextPage
}
return flatten(postsChunks)
}

View File

@ -0,0 +1,3 @@
fragment Author on AuthorRecord {
name
}

View File

@ -0,0 +1,21 @@
fragment BlogPost on BlogPostRecord {
_seoMetaTags {
...SEOTags
}
title
date
category {
title
slug
}
author {
...Author
}
slug
content {
value
}
image {
...Image
}
}

View File

@ -0,0 +1,5 @@
fragment Category on CategoryRecord {
id
title
slug
}

View File

@ -0,0 +1,7 @@
fragment Image on FileField {
url
alt
height
width
title
}

View File

@ -0,0 +1,5 @@
fragment SEOTags on Tag {
content
tag
attributes
}

2671
src/lib/cms/generated.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

18
src/lib/cms/index.ts Normal file
View File

@ -0,0 +1,18 @@
import { GraphQLClient } from 'graphql-request'
import { getSdk } from './generated'
const cms = (preview?: boolean) => {
const endpoint = 'https://graphql.datocms.com/'
const previewEndpoint = 'https://graphql.datocms.com/preview'
return getSdk(
new GraphQLClient(preview ? previewEndpoint : endpoint, {
headers: {
Authorization: `Bearer ${process.env.CMS_ACCESS_TOKEN}`
}
})
)
}
export default cms

View File

@ -0,0 +1,40 @@
query GetBlogPostsCategories {
allCategories {
...Category
}
}
query GetBlogPosts(
$skip: IntType!
$step: IntType!
$filterIds: [ItemId] = []
$filterSlugs: [String] = []
$category: [ItemId]
$search: String!
) {
_allBlogPostsMeta(
filter: {
title: { isBlank: false }
id: { notIn: $filterIds }
slug: { notIn: $filterSlugs }
category: { anyIn: $category }
OR: [{ title: { matches: { pattern: $search } } }]
}
) {
count
}
allBlogPosts(
filter: {
title: { isBlank: false }
id: { notIn: $filterIds }
slug: { notIn: $filterSlugs }
category: { anyIn: $category }
OR: [{ title: { matches: { pattern: $search } } }]
}
first: $step
orderBy: date_DESC
skip: $skip
) {
...BlogPost
}
}

View File

@ -0,0 +1,14 @@
query BlogPostsSlug($skip: IntType!, $step: IntType!) {
_allBlogPostsMeta {
count
}
allBlogPosts(first: $step, skip: $skip, orderBy: date_DESC) {
slug
}
}
query SingleBlogPost($slug: String!) {
blogPost(filter: { slug: { eq: $slug }, title: { isBlank: false } }) {
...BlogPost
}
}

View File

@ -0,0 +1,13 @@
@import '~/css/helpers';
.structured {
max-width: tovw(856px, 'default', 856px);
p {
font-family: var(--font-tt-hoves);
font-size: tovw(22px, 'default', 15px);
line-height: 160%;
letter-spacing: -0.01em;
margin: tovw(44px, 'default', 40px) 0;
}
}

72
src/lib/renderer/index.js Normal file
View File

@ -0,0 +1,72 @@
import { StructuredText } from 'react-datocms'
import Marked from '~/components/common/marked'
import { Container } from '~/components/layout/container'
import s from './blog.module.scss'
export const renderBlock = ({ record }) => {
switch (record.__typename) {
case 'ImageRecord':
return (
<div className="my-12">
<img
src={record.image?.url ?? '/4040404'}
width={record.image?.width ?? 0}
height={record.image?.height ?? 0}
alt={record.image?.alt ?? record.image?.title ?? 'blog image'}
/>
</div>
)
case 'CalloutRecord':
return <Marked>{record.content ?? ''}</Marked>
// case 'ShareLinkRecord':
// return (
// <div className="flex justify-center mt-20 space-x-4">
// <SocialLink variant="logo-only" tag="/" type="twitter" />
// <SocialLink variant="logo-only" tag="/" type="telegram" />
// <SocialLink variant="logo-only" tag="/" type="facebook" />
// <SocialLink variant="logo-only" type="copy-link" />
// </div>
// )
// case 'CtaRecord':
// return (
// <SectionCTAs
// className="flex flex-col items-center justify-center mt-20 space-y-2 sm:space-y-0 sm:space-x-2 sm:flex-row"
// ctas={
// record?.links?.map((link, i) => {
// return {
// href: link?.href ?? '',
// as: 'a',
// children: link?.label ?? '',
// variant: i === 0 ? 'primary' : 'tertiary',
// size: 'lg'
// }
// }) ?? []
// }
// />
// )
// case 'TableRecord':
// return (
// <ViewportWidthBox>
// <Container size="sm">
// <div className={s.table}>
// <Marked>{record.markdownTable ?? ''}</Marked>
// </div>
// </Container>
// </ViewportWidthBox>
// )
default:
return null
}
}
const CustomStructuredText = ({ data }) => {
return (
<Container className={s.structured}>
<StructuredText data={data} renderBlock={renderBlock} />
</Container>
)
}
export default CustomStructuredText

74
src/lib/utils/blog.ts Normal file
View File

@ -0,0 +1,74 @@
import { render } from 'datocms-structured-text-to-plain-text'
import { BlogPostFragment } from 'lib/cms/generated'
export const getPlainTextContent = (post: BlogPostFragment) => {
const text = render(post.content)
return text ?? ''
}
export const getDescription = (post: BlogPostFragment) => {
let text = getPlainTextContent(post)
const charLimit = 200
text = text.substring(0, charLimit)
let words = text.trim().split(/\s+/)
words = words.splice(0, words.length - 1)
const description = words.join(' ') + '...'
return description
}
export const getSeoTags = (post: BlogPostFragment) => {
const description = getDescription(post)
const filteredMetaTags = post._seoMetaTags.filter((t) => {
if (
t.attributes?.name === 'description' ||
t.attributes?.name === 'twitter:description' ||
t.attributes?.property === 'og:description' ||
t.tag === 'title'
) {
return false
}
return true
})
return [
...filteredMetaTags,
{
content: post.title + ' | Akash Network',
tag: 'title'
},
{
attributes: {
content: description,
name: 'description'
},
content: null,
tag: 'meta'
},
{
attributes: {
content: description,
property: 'og:description'
},
content: null,
tag: 'meta'
},
{
attributes: {
content: description,
name: 'twitter:description'
},
content: null,
tag: 'meta'
}
]
}
export type BlogPostWithMeta = Omit<BlogPostFragment, 'content'> & {
plainTextContent: string
numId: number
}
export type SingleBlogPost = BlogPostFragment & {
description: string
}

View File

@ -39,3 +39,60 @@ export const formatStatCount = (count = 0, withAbbr = false, decimals = 2) => {
}
return result
}
export const range = (start: number, stop?: number, step?: number) => {
if (typeof stop == 'undefined') {
// one param defined
stop = start
start = 0
}
if (typeof step == 'undefined') {
step = 1
}
if ((step > 0 && start >= stop) || (step < 0 && start <= stop)) {
return []
}
const result = []
for (let i = start; step > 0 ? i < stop : i > stop; i += step) {
result.push(i)
}
return result
}
export function getHash(input: string) {
let hash = 0
for (let i = 0; i < input.length; i++) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0 // to 32bit integer
}
return hash
}
export const formatDate = (date: string) => {
const [year, month, day] = date.split('-')
return `${month}.${day}.${year}`
}
export type PaginationData = {
page: number
nextPage: number | null
totalPages: number
step: number
total: number
}
export const getPagination = (
page: number,
total: number,
step: number
): PaginationData => ({
page: page,
nextPage: page < Math.ceil(total / step) ? page + 1 : null,
totalPages: Math.ceil(total / step),
step: step,
total
})

View File

@ -60,3 +60,12 @@ export const makeQuery = (
if (replace) return router.replace(url, url, { scroll: false, ...opts })
else return router.push(url, url, { scroll: false, ...opts })
}
export const getQueryParams = (params: QueryParams) => {
const searchParams = new URLSearchParams()
Object.keys(params).forEach((key) => {
const value = params[key]
if (value !== null) searchParams.append(key, value)
})
return searchParams
}

View File

@ -4,6 +4,7 @@ import { NextComponentType, NextPageContext } from 'next'
import { AppProps } from 'next/app'
import { RealViewportProvider } from 'next-real-viewport'
import * as React from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { Footer } from '~/components/common/footer'
import { Header } from '~/components/common/header'
@ -28,6 +29,8 @@ if (isProd) {
console.log(basementLog)
}
const queryClient = new QueryClient()
const App = ({ Component, pageProps, ...rest }: AppProps) => {
// useAppGA()
@ -55,16 +58,18 @@ const App = ({ Component, pageProps, ...rest }: AppProps) => {
(({ Component, pageProps }) => <Component {...pageProps} />)
return (
<RealViewportProvider debounceResize={false}>
<AnimationContextProvider>
{/* <GAScripts /> */}
<FontsReadyScript />
<Header />
<Mouse />
{getLayout({ Component, pageProps, ...rest })}
<Footer />
</AnimationContextProvider>
</RealViewportProvider>
<QueryClientProvider client={queryClient}>
<RealViewportProvider debounceResize={false}>
<AnimationContextProvider>
{/* <GAScripts /> */}
<FontsReadyScript />
<Header />
<Mouse />
{getLayout({ Component, pageProps, ...rest })}
<Footer />
</AnimationContextProvider>
</RealViewportProvider>
</QueryClientProvider>
)
}

View File

@ -0,0 +1,41 @@
import { getBlogPosts } from 'lib/blog'
import { NextApiRequest, NextApiResponse } from 'next'
type Params = {
secret?: string
page?: string
step?: string
filterIds?: string
category?: string
search?: string
locale?: string
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
const {
page: pageParam,
step: stepParam,
filterIds,
category,
search
} = req.query as Params
const page = pageParam ? Number(pageParam) : undefined
const step = stepParam ? Number(stepParam) : undefined
const filterIdsArr = filterIds ? filterIds.split(',') : undefined
try {
const blogPosts = await getBlogPosts({
page,
step,
filterIds: filterIdsArr,
category,
search
})
res.status(200).json(blogPosts)
} catch (error) {
console.error(error)
res.status(500).json({ message: (error as Error).message })
}
}

86
src/pages/blog/[slug].tsx Normal file
View File

@ -0,0 +1,86 @@
import { PageLayout } from 'components/layout/page'
import {
getAllBlogPostsSlugs,
getBlogPosts as serverGetBlogPosts
} from 'lib/blog'
import cms from 'lib/cms'
import {
GetStaticPaths,
GetStaticProps,
GetStaticPropsContext,
InferGetStaticPropsType
} from 'next'
import Error from 'next/error'
import { Key } from 'react'
import { BlogCard } from '~/components/common/card'
import Content from '~/components/sections/blog/post-content'
import Hero from '~/components/sections/blog/post-hero'
import PostsGrid from '~/components/sections/blog/posts-grid'
import { BlogPostFragment } from '~/lib/cms/generated'
const BlogPost = ({
latestPosts,
post
}: InferGetStaticPropsType<typeof getStaticProps>) => {
if (!post) {
return <Error statusCode={404} />
}
return (
<PageLayout>
<Hero data={post} />
<Content data={post} />
<PostsGrid>
{latestPosts.map((relatedPost: BlogPostFragment, index: Key) => {
return (
<div key={index}>
<BlogCard data={relatedPost} />
</div>
)
})}
</PostsGrid>
</PageLayout>
)
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getAllBlogPostsSlugs()
const paths = posts?.map((post) => ({
params: { slug: post.slug as string }
}))
return { paths, fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps = async (
ctx: GetStaticPropsContext
) => {
try {
const slug = ctx.params?.slug as string
const post = await (await cms().SingleBlogPost({ slug })).blogPost
const allBlogPosts = await serverGetBlogPosts({
filterSlugs: [slug],
step: 100
})
const relatedPosts = allBlogPosts.data.filter((p) =>
p.category.some((c) =>
post?.category.some((postCategory) => c.slug === postCategory.slug)
)
)
return {
props: {
latestPosts: relatedPosts.slice(0, 3),
post: post
},
revalidate: 1
}
} catch (error) {
return { notFound: true }
}
}
export default BlogPost

200
src/pages/blog/index.tsx Normal file
View File

@ -0,0 +1,200 @@
import { PageLayout } from 'components/layout/page'
import {
getBlogPosts as serverGetBlogPosts,
getBlogPostsCategories as serverGetBlogPostsCategories
} from 'lib/blog'
import { InferGetStaticPropsType } from 'next'
import { useRouter } from 'next/router'
import { Fragment, useCallback, useMemo } from 'react'
import { useInfiniteQuery } from 'react-query'
import { BlogCard } from '~/components/common/card'
import { Button, ButtonLink } from '~/components/primitives/button'
import Hero from '~/components/sections/blog/hero'
import PostsGrid from '~/components/sections/blog/posts-grid'
import SearchBar, {
LoadMoreContainer,
SearchContainer
} from '~/components/sections/blog/search'
import { CategoryFragment } from '~/lib/cms/generated'
import { getHrefWithQuery, getQueryParams, makeQuery } from '~/lib/utils/router'
const getMatchingCategory = (categories: CategoryFragment[], c?: string) =>
categories.find((cat) => cat.slug === c)
const BlogIndexPage = ({
categories,
initialBlogPosts,
page: { heroBlogPost }
}: InferGetStaticPropsType<typeof getStaticProps>) => {
const router = useRouter()
const { c, s } = router.query
const activeCategory = useMemo(
() => getMatchingCategory(categories, c as string),
[c, categories]
)
const handleSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
makeQuery(router, { s: e.target.value || null }, { replace: true })
},
[router]
)
const {
data: queriedPosts,
fetchNextPage,
hasNextPage,
isLoading,
isFetching
} = useInfiniteQuery(
['posts', router.locale, c, s],
({ pageParam, queryKey }) => {
const matchingCat = getMatchingCategory(categories, queryKey[2] as string)
return clientGetBlogPosts({
page: pageParam,
category: matchingCat?.id ?? null,
search: queryKey[3] as string
})
},
{
refetchOnWindowFocus: false,
initialData: !activeCategory
? {
pageParams: [undefined],
pages: [initialBlogPosts]
}
: undefined,
getNextPageParam: (lastPage) => lastPage.pagination.nextPage ?? undefined
}
)
const hasResults = !!queriedPosts?.pages[0]?.data?.length
return (
<PageLayout>
<Hero featuredPost={heroBlogPost} />
<SearchContainer>
<nav>
<ButtonLink
href={getHrefWithQuery(router.asPath, {
c: null
})}
size="small"
>
All
</ButtonLink>
{categories.map((category) => {
return (
<ButtonLink
key={category.slug}
href={getHrefWithQuery(router.asPath, {
c: category.slug as string
})}
size="small"
>
{category.title}
</ButtonLink>
)
})}
</nav>
<SearchBar
search={!Array.isArray(s) ? s : ''}
onChange={handleSearch}
key={'search'}
/>
</SearchContainer>
<PostsGrid>
{!isLoading ? (
hasResults ? (
queriedPosts?.pages?.map((page, pageIdx) => (
<Fragment key={pageIdx}>
{page.data.map((post, postIdx) => (
<BlogCard data={post} key={postIdx} />
))}
</Fragment>
))
) : (
<p>No results.</p>
)
) : (
<p>Loading...</p>
)}
</PostsGrid>
{hasNextPage && (
<LoadMoreContainer>
<Button disabled={isFetching} onClick={() => fetchNextPage()}>
View More
</Button>
</LoadMoreContainer>
)}
</PageLayout>
)
}
const clientGetBlogPosts = async ({
page,
step,
filterIds,
category,
search,
locale
}: {
page?: number
step?: number
filterIds?: string[]
category?: string
search?: string
locale?: string
}): Promise<{
pagination: {
page: number
nextPage: number
totalPages: number
step: number
total: number
}
data: any[]
}> => {
const searchParams = getQueryParams({
page: page?.toString() || null,
step: step?.toString() || null,
filterIds: filterIds?.join(',') || null,
category: category || null,
search: search || null,
locale: locale || null
})
const allBlogPosts = await (
await fetch(`/api/blog-posts?${searchParams.toString()}`)
).json()
return allBlogPosts
}
export const getStaticProps = async () => {
const [allBlogPosts, categories] = await Promise.all([
serverGetBlogPosts({ page: 1 }),
serverGetBlogPostsCategories()
])
const heroBlogPost = allBlogPosts.data[0]
return {
props: {
initialBlogPosts: {
pagination: allBlogPosts.pagination,
data: allBlogPosts.data.slice(0, 9)
},
categories,
page:
{
heroBlogPost
} ?? null
},
revalidate: 1
}
}
export default BlogIndexPage

2781
yarn.lock

File diff suppressed because it is too large Load Diff