mirror of
https://github.com/LaconicNetwork/laconic.com.git
synced 2026-01-16 23:14:07 +00:00
Blog page (#15)
This commit is contained in:
parent
4e8b9d5a64
commit
a5e0829ed4
43
codegen-fix.js
Normal file
43
codegen-fix.js
Normal 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
22
gql-codegen.yml
Normal 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
8306
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -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
6
public/sitemap-0.xml
Normal 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
4
public/sitemap.xml
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
16
src/components/common/category/category.module.scss
Normal file
16
src/components/common/category/category.module.scss
Normal 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;
|
||||
}
|
||||
14
src/components/common/category/index.tsx
Normal file
14
src/components/common/category/index.tsx
Normal 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
|
||||
27
src/components/common/marked/index.tsx
Normal file
27
src/components/common/marked/index.tsx
Normal 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
|
||||
21
src/components/common/marked/marked.module.scss
Normal file
21
src/components/common/marked/marked.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/components/sections/blog/hero/hero.module.scss
Normal file
26
src/components/sections/blog/hero/hero.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/components/sections/blog/hero/index.tsx
Normal file
26
src/components/sections/blog/hero/index.tsx
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/components/sections/blog/post-content/index.tsx
Normal file
32
src/components/sections/blog/post-content/index.tsx
Normal 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
|
||||
52
src/components/sections/blog/post-hero/hero.module.scss
Normal file
52
src/components/sections/blog/post-hero/hero.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/components/sections/blog/post-hero/index.tsx
Normal file
36
src/components/sections/blog/post-hero/index.tsx
Normal 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
|
||||
18
src/components/sections/blog/posts-grid/index.tsx
Normal file
18
src/components/sections/blog/posts-grid/index.tsx
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
25
src/components/sections/blog/search/icon.tsx
Normal file
25
src/components/sections/blog/search/icon.tsx
Normal 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
|
||||
54
src/components/sections/blog/search/index.tsx
Normal file
54
src/components/sections/blog/search/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/sections/blog/search/search.module.scss
Normal file
72
src/components/sections/blog/search/search.module.scss
Normal 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
98
src/lib/blog.ts
Normal 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)
|
||||
}
|
||||
3
src/lib/cms/fragments/author.gql
Normal file
3
src/lib/cms/fragments/author.gql
Normal file
@ -0,0 +1,3 @@
|
||||
fragment Author on AuthorRecord {
|
||||
name
|
||||
}
|
||||
21
src/lib/cms/fragments/blog-post.gql
Normal file
21
src/lib/cms/fragments/blog-post.gql
Normal file
@ -0,0 +1,21 @@
|
||||
fragment BlogPost on BlogPostRecord {
|
||||
_seoMetaTags {
|
||||
...SEOTags
|
||||
}
|
||||
title
|
||||
date
|
||||
category {
|
||||
title
|
||||
slug
|
||||
}
|
||||
author {
|
||||
...Author
|
||||
}
|
||||
slug
|
||||
content {
|
||||
value
|
||||
}
|
||||
image {
|
||||
...Image
|
||||
}
|
||||
}
|
||||
5
src/lib/cms/fragments/category.gql
Normal file
5
src/lib/cms/fragments/category.gql
Normal file
@ -0,0 +1,5 @@
|
||||
fragment Category on CategoryRecord {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
7
src/lib/cms/fragments/image.gql
Normal file
7
src/lib/cms/fragments/image.gql
Normal file
@ -0,0 +1,7 @@
|
||||
fragment Image on FileField {
|
||||
url
|
||||
alt
|
||||
height
|
||||
width
|
||||
title
|
||||
}
|
||||
5
src/lib/cms/fragments/seo-tags.gql
Normal file
5
src/lib/cms/fragments/seo-tags.gql
Normal file
@ -0,0 +1,5 @@
|
||||
fragment SEOTags on Tag {
|
||||
content
|
||||
tag
|
||||
attributes
|
||||
}
|
||||
2671
src/lib/cms/generated.ts
Normal file
2671
src/lib/cms/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
11359
src/lib/cms/graphql.schema.json
Normal file
11359
src/lib/cms/graphql.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
18
src/lib/cms/index.ts
Normal file
18
src/lib/cms/index.ts
Normal 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
|
||||
40
src/lib/cms/queries/blog-page.gql
Normal file
40
src/lib/cms/queries/blog-page.gql
Normal 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
|
||||
}
|
||||
}
|
||||
14
src/lib/cms/queries/blog-post.gql
Normal file
14
src/lib/cms/queries/blog-post.gql
Normal 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
|
||||
}
|
||||
}
|
||||
13
src/lib/renderer/blog.module.scss
Normal file
13
src/lib/renderer/blog.module.scss
Normal 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
72
src/lib/renderer/index.js
Normal 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
74
src/lib/utils/blog.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
41
src/pages/api/blog-posts.ts
Normal file
41
src/pages/api/blog-posts.ts
Normal 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
86
src/pages/blog/[slug].tsx
Normal 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
200
src/pages/blog/index.tsx
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user