mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
community: add nextjs-incremental-static-regeneration
GITHUB_PR_NUMBER: 8320 GITHUB_PR_URL: https://github.com/hasura/graphql-engine/pull/8320 PR-URL: https://github.com/hasura/graphql-engine-mono/pull/3976 Co-authored-by: arjunyel <11153289+arjunyel@users.noreply.github.com> GitOrigin-RevId: a3d8f705900c814a3f2017b239b86960943c8677
This commit is contained in:
parent
59be818d3a
commit
810c94c776
@ -0,0 +1 @@
|
|||||||
|
SECRET_TOKEN=HELLO
|
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
38
community/sample-apps/nextjs-incremental-static-regeneration/.gitignore
vendored
Normal file
38
community/sample-apps/nextjs-incremental-static-regeneration/.gitignore
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
@ -0,0 +1,150 @@
|
|||||||
|
# Trigger Next.js Incremental Static Regeneration with a Hasura Table Event
|
||||||
|
|
||||||
|
New in Next.js 12.1 is [Incremental Static Regeneration](https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration) which allows us to create and update pages on demand. We can pair this with Hasura table events to keep our web pages always up to date and only rebuild when data changes.
|
||||||
|
|
||||||
|
Lets setup an example blog app to check this out.
|
||||||
|
|
||||||
|
## Setup Hasura
|
||||||
|
|
||||||
|
1. [Download the Hasura Docker Compose file.](https://hasura.io/docs/latest/graphql/core/getting-started/docker-simple.html#step-1-get-the-docker-compose-file)
|
||||||
|
|
||||||
|
1. In the Docker Compose graphql-engine environment variable section add `SECRET_TOKEN: <a random string you come up with>`
|
||||||
|
|
||||||
|
1. [Start Hasura and launch the console.](https://hasura.io/docs/latest/graphql/core/getting-started/docker-simple.html#step-2-run-hasura-graphql-engine)
|
||||||
|
|
||||||
|
1. In the data tab we create a new table `post` with the following columns:
|
||||||
|
|
||||||
|
- `id` UUID from the frequently used columns
|
||||||
|
- `title` type text
|
||||||
|
- `content` type text
|
||||||
|
|
||||||
|
1. In the API tab we construct our GraphQL query
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
{
|
||||||
|
post {
|
||||||
|
content
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Next.js
|
||||||
|
|
||||||
|
1. We create an example typescript Next.js app `npx create-next-app@latest --ts`
|
||||||
|
|
||||||
|
1. We will query Hasura using [graphql-request](https://github.com/prisma-labs/graphql-request). Install with `npm install graphql-request graphql`
|
||||||
|
|
||||||
|
1. In `index.tsx` we setup our getStaticProps data fetching
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { request, gql } from "graphql-request";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
posts: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = gql`
|
||||||
|
{
|
||||||
|
post {
|
||||||
|
content
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const { post: posts } = await request(
|
||||||
|
"http://localhost:8080/v1/graphql",
|
||||||
|
query
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
posts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Finally we display our blog posts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const Home: NextPage<Props> = ({ posts }) => {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<article key={post.id}>
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.content}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Incremental Static Regeneration
|
||||||
|
|
||||||
|
When running Next.js in production mode if we add a new blog post in Hasura Next.js has no way to know about it. In the past, before Incremental Static Regeneration, we would have to set a [revalidate time](https://nextjs.org/docs/api-reference/data-fetching/get-static-props#revalidate), where it would rebuild a page every x amount of time even if there wasn't anything new.
|
||||||
|
|
||||||
|
Now we can setup a webhook where we can tell Next.js to rebuild specific pages when our data changes! [Following the Next.js on-demand revalidation guide:](https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#using-on-demand-revalidation)
|
||||||
|
|
||||||
|
1. When we setup our Docker Compose we came up with a `SECRET_TOKEN`, we use that as a password to communicate with Next.js. Create .env.local file
|
||||||
|
|
||||||
|
```env
|
||||||
|
SECRET_TOKEN=<Same as what you set in Docker Compose>
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Create the revalidation API route, the only difference from the official example is we look for the secret token in the header instead of a query variable.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/api/revalidate.ts
|
||||||
|
// From Next.js docs https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#using-on-demand-revalidation
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
// Check for secret to confirm this is a valid request
|
||||||
|
if (req.headers.secret !== process.env.SECRET_TOKEN) {
|
||||||
|
return res.status(401).json({ message: "Invalid token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await res.unstable_revalidate("/");
|
||||||
|
return res.json({ revalidated: true });
|
||||||
|
} catch (err) {
|
||||||
|
// If there was an error, Next.js will continue
|
||||||
|
// to show the last successfully generated page
|
||||||
|
return res.status(500).send("Error revalidating");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Back in the Hasura console events tab, create an event trigger.
|
||||||
|
|
||||||
|
- Name the trigger anything, select the post table, and select all trigger operations.
|
||||||
|
- With docker, the webhook handler should be `http://host.docker.internal/api/revalidate`
|
||||||
|
- Under Advanced Settings we add the SECRET header from the `SECRET_TOKEN` environment variable
|
||||||
|
|
||||||
|
1. Save the event trigger and [run Next.js in production mode](https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#testing-on-demand-isr-during-development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
Now when you add a post in Hasura, when you refresh you Next.js app you'll see your updated data!
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Checkout the [contributing guide](../../../CONTRIBUTING.md#community-content) for more details.
|
@ -0,0 +1,31 @@
|
|||||||
|
version: "3.6"
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:13
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: postgrespassword
|
||||||
|
graphql-engine:
|
||||||
|
image: hasura/graphql-engine:v2.3.0
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- "postgres"
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
## postgres database to store Hasura metadata
|
||||||
|
HASURA_GRAPHQL_METADATA_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
|
||||||
|
## this env var can be used to add the above postgres database to Hasura as a data source. this can be removed/updated based on your needs
|
||||||
|
PG_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
|
||||||
|
## enable the console served by server
|
||||||
|
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
|
||||||
|
## enable debugging mode. It is recommended to disable this in production
|
||||||
|
HASURA_GRAPHQL_DEV_MODE: "true"
|
||||||
|
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
|
||||||
|
## uncomment next line to set an admin secret
|
||||||
|
# HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
|
||||||
|
SECRET_TOKEN: HELLO
|
||||||
|
volumes:
|
||||||
|
db_data:
|
@ -0,0 +1,6 @@
|
|||||||
|
version: 3
|
||||||
|
endpoint: http://localhost:8080
|
||||||
|
metadata_directory: metadata
|
||||||
|
actions:
|
||||||
|
kind: synchronous
|
||||||
|
handler_webhook_baseurl: http://localhost:3000
|
@ -0,0 +1,6 @@
|
|||||||
|
actions: []
|
||||||
|
custom_types:
|
||||||
|
enums: []
|
||||||
|
input_objects: []
|
||||||
|
objects: []
|
||||||
|
scalars: []
|
@ -0,0 +1 @@
|
|||||||
|
[]
|
@ -0,0 +1 @@
|
|||||||
|
[]
|
@ -0,0 +1,9 @@
|
|||||||
|
- name: default
|
||||||
|
kind: postgres
|
||||||
|
configuration:
|
||||||
|
connection_info:
|
||||||
|
database_url:
|
||||||
|
from_env: PG_DATABASE_URL
|
||||||
|
isolation_level: read-committed
|
||||||
|
use_prepared_statements: false
|
||||||
|
tables: "!include default/tables/tables.yaml"
|
@ -0,0 +1,21 @@
|
|||||||
|
table:
|
||||||
|
name: post
|
||||||
|
schema: public
|
||||||
|
event_triggers:
|
||||||
|
- definition:
|
||||||
|
delete:
|
||||||
|
columns: "*"
|
||||||
|
enable_manual: true
|
||||||
|
insert:
|
||||||
|
columns: "*"
|
||||||
|
update:
|
||||||
|
columns: "*"
|
||||||
|
headers:
|
||||||
|
- name: SECRET
|
||||||
|
value_from_env: SECRET_TOKEN
|
||||||
|
name: update_blog
|
||||||
|
retry_conf:
|
||||||
|
interval_sec: 10
|
||||||
|
num_retries: 0
|
||||||
|
timeout_sec: 60
|
||||||
|
webhook: http://host.docker.internal:3000/api/revalidate
|
@ -0,0 +1 @@
|
|||||||
|
- "!include public_post.yaml"
|
@ -0,0 +1 @@
|
|||||||
|
[]
|
@ -0,0 +1 @@
|
|||||||
|
[]
|
@ -0,0 +1 @@
|
|||||||
|
[]
|
@ -0,0 +1 @@
|
|||||||
|
version: 3
|
@ -0,0 +1,8 @@
|
|||||||
|
SET check_function_bodies = false;
|
||||||
|
CREATE TABLE public.post (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
content text NOT NULL
|
||||||
|
);
|
||||||
|
ALTER TABLE ONLY public.post
|
||||||
|
ADD CONSTRAINT blog_pkey PRIMARY KEY (id);
|
5
community/sample-apps/nextjs-incremental-static-regeneration/next-env.d.ts
vendored
Normal file
5
community/sample-apps/nextjs-incremental-static-regeneration/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
5131
community/sample-apps/nextjs-incremental-static-regeneration/package-lock.json
generated
Normal file
5131
community/sample-apps/nextjs-incremental-static-regeneration/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "nextjs-incremental-static-regeneration",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"graphql": "^16.3.0",
|
||||||
|
"graphql-request": "^4.1.0",
|
||||||
|
"next": "12.1.0",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-dom": "17.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "17.0.21",
|
||||||
|
"@types/react": "17.0.39",
|
||||||
|
"eslint": "8.10.0",
|
||||||
|
"eslint-config-next": "12.1.0",
|
||||||
|
"typescript": "4.6.2"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import '../styles/globals.css'
|
||||||
|
import type { AppProps } from 'next/app'
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }: AppProps) {
|
||||||
|
return <Component {...pageProps} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp
|
@ -0,0 +1,21 @@
|
|||||||
|
// From Next.js docs https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#using-on-demand-revalidation
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
// Check for secret to confirm this is a valid request
|
||||||
|
if (req.headers.secret !== process.env.SECRET_TOKEN) {
|
||||||
|
return res.status(401).json({ message: "Invalid token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await res.unstable_revalidate("/");
|
||||||
|
return res.json({ revalidated: true });
|
||||||
|
} catch (err) {
|
||||||
|
// If there was an error, Next.js will continue
|
||||||
|
// to show the last successfully generated page
|
||||||
|
return res.status(500).send("Error revalidating");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import type { NextPage } from "next";
|
||||||
|
import { request, gql } from "graphql-request";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
posts: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = gql`
|
||||||
|
{
|
||||||
|
post {
|
||||||
|
content
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const { post: posts } = await request(
|
||||||
|
"http://localhost:8080/v1/graphql",
|
||||||
|
query
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
posts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Home: NextPage<Props> = ({ posts }) => {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<article key={post.id}>
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.content}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,116 @@
|
|||||||
|
.container {
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 4rem 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem 0;
|
||||||
|
border-top: 1px solid #eaeaea;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a {
|
||||||
|
color: #0070f3;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title a:hover,
|
||||||
|
.title a:focus,
|
||||||
|
.title a:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.description {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 4rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.card:focus,
|
||||||
|
.card:active {
|
||||||
|
color: #0070f3;
|
||||||
|
border-color: #0070f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user