├── lib ├── schemas │ ├── home.graphql │ ├── settings.graphql │ ├── profile.graphql │ ├── editor.graphql │ ├── auth.graphql │ ├── article-list.graphql │ └── article.graphql ├── utils │ ├── check-literal.ts │ ├── styles-builder.ts │ ├── compose.tsx │ ├── genericFormField.ts │ ├── date-format.ts │ └── markdown.ts ├── api │ ├── context.ts │ ├── types │ │ ├── scalar.type.ts │ │ ├── base.type.ts │ │ ├── comment.type.ts │ │ ├── user.type.ts │ │ └── article.type.ts │ ├── query │ │ ├── tag.query.ts │ │ ├── comment.query.ts │ │ ├── user.query.ts │ │ └── article.query.ts │ ├── prisma.ts │ ├── utils.ts │ ├── mutation │ │ ├── comment.mutation.ts │ │ ├── profile.mutation.ts │ │ ├── user.mutation.ts │ │ └── article.mutation.ts │ └── schema.ts ├── hooks │ ├── use-previous.ts │ ├── use-router-methods.ts │ ├── use-current-user.ts │ ├── use-token.tsx │ ├── use-apollo.tsx │ ├── use-form-callback.ts │ ├── use-validation.ts │ └── use-message.tsx ├── auth │ ├── with-auth.tsx │ └── guest-only.tsx ├── seo │ └── index.ts ├── client │ └── apollo-client.ts ├── cache │ └── index.ts ├── validation │ └── schema.ts └── constants.ts ├── .prettierrc.yml ├── .husky └── pre-commit ├── public └── favicon.ico ├── .eslintrc.json ├── components ├── common │ ├── ErrorMessage.tsx │ ├── Title.tsx │ ├── Container.tsx │ ├── ContainerPage.tsx │ ├── Tab.tsx │ ├── NavItem.tsx │ ├── TabList.tsx │ ├── Layout.tsx │ ├── wrapper.tsx │ ├── Footer.tsx │ ├── Errors.tsx │ ├── NavLink.tsx │ ├── TagList.tsx │ ├── CustomImage.tsx │ ├── toast.tsx │ ├── LoadMore.tsx │ ├── alert.tsx │ ├── LoadingSpinner.tsx │ ├── Tag.tsx │ ├── FollowsButton.tsx │ ├── reverse-load-more.tsx │ ├── CustomLink.tsx │ ├── FavoritesButton.tsx │ ├── FormGroup.tsx │ ├── GenericForm.tsx │ ├── Header.tsx │ └── CustomButton.tsx ├── home │ ├── Banner.tsx │ └── Sidebar.tsx ├── forms │ ├── FormErrorMessage.tsx │ ├── control-input.tsx │ ├── TextAreaInput.tsx │ ├── textarea.tsx │ ├── submit.tsx │ ├── form.tsx │ ├── form-teextarea.tsx │ ├── TagInput.tsx │ ├── FormInput.tsx │ ├── Input.tsx │ ├── CustomInput.tsx │ ├── tag-input.tsx │ └── CustomForm.tsx ├── article │ ├── ArticlePageBanner.tsx │ ├── article-json-meta.tsx │ ├── marked.tsx │ ├── ArticleMeta.tsx │ ├── ArticleAuthorInfo.tsx │ ├── NonOwnerArticleMetaActions.tsx │ ├── OwnerArticleMetaActions.tsx │ ├── ArticleComment.tsx │ ├── CommentForm.tsx │ └── CommentSection.tsx ├── article-list │ ├── Pagination.tsx │ ├── ArticleList.tsx │ ├── ArticlePreview.tsx │ └── ArticlesViewer.tsx ├── profile │ └── UserInfo.tsx └── editor │ └── ArticleEditor.tsx ├── .editorconfig ├── postcss.config.js ├── config.js ├── next-env.d.ts ├── serverless.yml ├── .graphql-codegen.yml ├── .yarnrc.yml ├── gen-jwk.js ├── styles └── global.css ├── pages ├── editor │ ├── index.tsx │ └── [slug].tsx ├── _app.tsx ├── 404.tsx ├── profile │ └── [username].tsx ├── index.tsx ├── article │ └── [slug].tsx ├── api │ └── index.ts ├── login.tsx ├── register.tsx └── settings.tsx ├── .env.example ├── tsconfig.json ├── next.config.js ├── next.config.original.js ├── LICENSE ├── tailwind.config.js ├── .gitignore ├── generated └── schema.graphql ├── package.json ├── prisma └── schema.prisma └── README.md /lib/schemas/home.graphql: -------------------------------------------------------------------------------- 1 | query Tags { 2 | tags 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 120 2 | singleQuote: true 3 | jsxSingleQuote: true 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimleestone/next-real-world/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@typescript-eslint/no-non-null-assertion": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/utils/check-literal.ts: -------------------------------------------------------------------------------- 1 | export function isInArray(item: T, array: ReadonlyArray): item is A { 2 | return array.includes(item as A); 3 | } 4 | -------------------------------------------------------------------------------- /components/common/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | export default function ErrorMessage({ message }: { message: string }) { 2 | return
{message}
; 3 | } 4 | -------------------------------------------------------------------------------- /lib/api/context.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | export interface Context { 4 | prisma: PrismaClient; 5 | currentUser?: { id: number }; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /lib/api/types/scalar.type.ts: -------------------------------------------------------------------------------- 1 | import { DateTimeResolver } from 'graphql-scalars'; 2 | import { asNexusMethod } from 'nexus'; 3 | 4 | export const DateTime = asNexusMethod(DateTimeResolver, 'date'); 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}), 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /lib/schemas/settings.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateUser($input: UserUpdateInput!) { 2 | updateUser(input: $input) { 3 | id 4 | username 5 | email 6 | bio 7 | image 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | supportedHighlightLangs: ['javascript', 'python', 'ruby', 'json', 'yaml', 'typescript', 'bash', 'java'], 3 | supportedLocales: ['ja', 'en-US'], 4 | }; 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | next-real-world: 2 | component: './node_modules/@sls-next/serverless-component' 3 | inputs: 4 | bucketName: 'next-real-world' 5 | bucketRegion: 'ap-northeast-3' 6 | minifyHandlers: true 7 | useServerlessTraceTarget: true 8 | -------------------------------------------------------------------------------- /lib/api/types/base.type.ts: -------------------------------------------------------------------------------- 1 | import { interfaceType } from 'nexus'; 2 | 3 | const Node = interfaceType({ 4 | name: 'Node', 5 | definition(t) { 6 | t.nonNull.int('id'); 7 | }, 8 | }); 9 | 10 | const BaseTypes = [Node]; 11 | export default BaseTypes; 12 | -------------------------------------------------------------------------------- /components/common/Title.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | export default function Title({ title }: { title: string }) { 4 | return ( 5 |
6 | 7 | {title} | Conduit 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /.graphql-codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 'http://localhost:3000/api' 3 | documents: './lib/schemas/*.graphql' 4 | generates: 5 | generated/graphql.ts: 6 | plugins: 7 | - 'typescript' 8 | - 'typescript-operations' 9 | - 'typescript-react-apollo' 10 | -------------------------------------------------------------------------------- /components/common/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Container({ className, children }: { className?: string; children: React.ReactNode }) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /components/common/ContainerPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function ContainerPage({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
{children}
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableTelemetry: false 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 7 | spec: '@yarnpkg/plugin-workspace-tools' 8 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 9 | spec: '@yarnpkg/plugin-interactive-tools' 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 12 | -------------------------------------------------------------------------------- /gen-jwk.js: -------------------------------------------------------------------------------- 1 | const jose = require('jose'); 2 | 3 | async function gen() { 4 | const { publicKey, privateKey } = await jose.generateKeyPair('RS256'); 5 | const privateJwk = await jose.exportJWK(privateKey); 6 | const publicJwk = await jose.exportJWK(publicKey); 7 | 8 | console.log(JSON.stringify(privateJwk)); 9 | console.log(publicJwk); 10 | } 11 | gen(); 12 | -------------------------------------------------------------------------------- /lib/utils/styles-builder.ts: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | 3 | export const spacer = R.join(' '); 4 | export function joinStyles(styles: Record): string { 5 | return spacer(R.values(styles)); 6 | } 7 | 8 | export function joinStylesFromArray(...styles: Array): string { 9 | return spacer(styles.filter((s) => typeof s === 'string')); 10 | } 11 | -------------------------------------------------------------------------------- /components/common/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { LinkProps } from 'next/link'; 2 | import CustomLink from './CustomLink'; 3 | 4 | export type TabProps = { 5 | name: string; 6 | } & LinkProps; 7 | 8 | export default function Tab({ name, ...props }: TabProps) { 9 | return ( 10 | 11 | {name} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils/compose.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | components: Array>>; 3 | children: React.ReactNode; 4 | } 5 | 6 | export default function Compose(props: Props) { 7 | const { components = [], children } = props; 8 | 9 | return ( 10 | <> 11 | {components.reduceRight((acc, Comp) => { 12 | return {acc}; 13 | }, children)} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/common/NavItem.tsx: -------------------------------------------------------------------------------- 1 | import { LinkProps } from 'next/link'; 2 | import CustomLink from './CustomLink'; 3 | 4 | export type NavItemProps = { 5 | text: string; 6 | icon?: string; 7 | } & LinkProps; 8 | 9 | export default function NavItem({ text, icon, ...props }: NavItemProps) { 10 | return ( 11 | 12 | {icon && }  13 | {text} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/common/TabList.tsx: -------------------------------------------------------------------------------- 1 | import Tab, { TabProps } from './Tab'; 2 | 3 | export default function TabList({ tabs }: { tabs: TabProps[] }) { 4 | return ( 5 |
6 |
    7 | {tabs.map(({ name, href, as }) => ( 8 |
  • 9 | 10 |
  • 11 | ))} 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/home/Banner.tsx: -------------------------------------------------------------------------------- 1 | export default function HomeBanner() { 2 | return ( 3 |
4 |
5 |

conduit

6 |

A place to share your knowledge.

7 |
8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | @import url('//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css'); 2 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP&family=Noto+Sans+Mono&family=Noto+Serif+JP&family=Source+Code+Pro&family=Titillium+Web:wght@700&family=Ubuntu+Mono&family=Zen+Kaku+Gothic+New:wght@900&display=swap'); 3 | @import url('https://unpkg.com/highlight.js@11.6.0/styles/tokyo-night-dark.css'); 4 | 5 | @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; 8 | -------------------------------------------------------------------------------- /lib/schemas/profile.graphql: -------------------------------------------------------------------------------- 1 | query Profile($username: String!) { 2 | profile(username: $username) { 3 | username 4 | bio 5 | image 6 | following 7 | } 8 | } 9 | 10 | mutation Follow($username: String!) { 11 | follow(username: $username) { 12 | ...Follows 13 | } 14 | } 15 | 16 | mutation UnFollow($username: String!) { 17 | unFollow(username: $username) { 18 | ...Follows 19 | } 20 | } 21 | 22 | fragment Follows on Profile { 23 | username 24 | following 25 | } 26 | -------------------------------------------------------------------------------- /pages/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import Wrapper from '../../components/common/wrapper'; 2 | import ArticleEditor from '../../components/editor/ArticleEditor'; 3 | import { AuthUser } from '../../generated/graphql'; 4 | import withAuth from '../../lib/auth/with-auth'; 5 | 6 | const NewArticle = ({ user }: { user: AuthUser }) => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default withAuth(NewArticle); 15 | -------------------------------------------------------------------------------- /components/forms/FormErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { joinStylesFromArray } from '../../lib/utils/styles-builder'; 2 | 3 | type FormErrorMessageProps = Partial<{ 4 | className: string; 5 | message: string; 6 | }>; 7 | 8 | export default function FormErrorMessage({ message, className }: FormErrorMessageProps) { 9 | return ( 10 |
11 | {message &&

{message}

} 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils/genericFormField.ts: -------------------------------------------------------------------------------- 1 | export interface GenericFormField { 2 | name: string; 3 | type: string; 4 | placeholder: string; 5 | rows?: number; 6 | fieldType: 'input' | 'textarea' | 'list'; 7 | listName?: string; 8 | lg: boolean; 9 | } 10 | 11 | export function buildGenericFormField(data: Partial & { name: string }): GenericFormField { 12 | return { 13 | type: 'text', 14 | placeholder: '', 15 | fieldType: 'input', 16 | lg: true, 17 | ...data, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /components/article/ArticlePageBanner.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleViewFragment } from '../../generated/graphql'; 2 | import ArticleMeta from './ArticleMeta'; 3 | 4 | export default function ArticlePageBanner(props: { article: ArticleViewFragment }) { 5 | return ( 6 |
7 |
8 |

{props.article.title}

9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/home/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useTagsQuery } from '../../generated/graphql'; 2 | import LoadingSpinner from '../common/LoadingSpinner'; 3 | import TagList from '../common/TagList'; 4 | 5 | export default function HomeSidebar() { 6 | const { loading, data } = useTagsQuery(); 7 | if (loading) return ; 8 | return ( 9 |
10 |

Popular Tags

11 | {data && } 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/hooks/use-previous.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function usePrevious(value: T) { 4 | // The ref object is a generic container whose current property is mutable ... 5 | // ... and can hold any value, similar to an instance property on a class 6 | const ref = useRef(); 7 | // Store current value in ref 8 | useEffect(() => { 9 | ref.current = value; 10 | }, [value]); // Only re-run if value changes 11 | // Return previous value (happens before update in useEffect above) 12 | return ref.current; 13 | } 14 | -------------------------------------------------------------------------------- /components/common/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultSeo } from 'next-seo'; 2 | import type { AppProps } from 'next/app'; 3 | import seo from '../../lib/seo'; 4 | import Footer from './Footer'; 5 | import Header from './Header'; 6 | import Toast from './toast'; 7 | 8 | export default function Layout({ Component, pageProps }: AppProps) { 9 | return ( 10 | <> 11 | 12 |
13 |
14 | 15 |
16 | 17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="mysql://root:pass@localhost/real_blog?useUnicode=true&characterEncoding=utf8&useSSL=false" 8 | PRIVATE_JWK={your_generated_private_jwk} 9 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app'; 2 | import Layout from '../components/common/Layout'; 3 | import { CustomApolloProvider } from '../lib/hooks/use-apollo'; 4 | import { MessageProvider } from '../lib/hooks/use-message'; 5 | import { TokenProvider } from '../lib/hooks/use-token'; 6 | import Compose from '../lib/utils/compose'; 7 | import '../styles/global.css'; 8 | 9 | function MyApp(appProps: AppProps) { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | export default MyApp; 18 | -------------------------------------------------------------------------------- /components/common/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo, NextSeoProps } from 'next-seo'; 2 | import { ReactNode } from 'react'; 3 | import { useMessageHandler } from '../../lib/hooks/use-message'; 4 | import LoadingSpinner from './LoadingSpinner'; 5 | 6 | type WrapperProps = { 7 | children: ReactNode; 8 | } & NextSeoProps; 9 | 10 | export default function Wrapper({ children, ...props }: WrapperProps) { 11 | const { dismissing } = useMessageHandler(); 12 | if (dismissing) return ; 13 | return ( 14 | <> 15 | 16 |
{children}
17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /lib/auth/with-auth.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import LoadingSpinner from '../../components/common/LoadingSpinner'; 3 | import { useCurrentUser } from '../hooks/use-current-user'; 4 | import { usePush } from '../hooks/use-router-methods'; 5 | 6 | export default function withAuth(Component: any) { 7 | const AuthenticatedComponent = () => { 8 | const push = usePush(); 9 | const { user, loading } = useCurrentUser(); 10 | useEffect(() => { 11 | if (!loading && !user) push('/login'); 12 | }, [user, loading, push]); 13 | return user ? : ; 14 | }; 15 | return AuthenticatedComponent; 16 | } 17 | -------------------------------------------------------------------------------- /lib/schemas/editor.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateArticle($input: ArticleInput!) { 2 | createArticle(input: $input) { 3 | id 4 | slug 5 | } 6 | } 7 | 8 | mutation UpdateArticle($slug: String!, $input: ArticleInput!) { 9 | updateArticle(slug: $slug, input: $input) { 10 | id 11 | slug 12 | title 13 | body 14 | description 15 | tagList 16 | } 17 | } 18 | 19 | query EditArticle($slug: String!) { 20 | article(slug: $slug) { 21 | ...EditArticleView 22 | } 23 | } 24 | 25 | fragment EditArticleView on Article { 26 | id 27 | slug 28 | title 29 | body 30 | description 31 | tagList 32 | author { 33 | username 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /components/article/article-json-meta.tsx: -------------------------------------------------------------------------------- 1 | import { ArticleJsonLd } from 'next-seo'; 2 | import { ArticleViewFragment } from '../../generated/graphql'; 3 | import { BASE_URL } from '../../lib/constants'; 4 | 5 | export default function ArticleJsonMeta({ article }: { article: ArticleViewFragment }) { 6 | const { slug, title, description, createdAt, updatedAt, author } = article; 7 | const { username } = author; 8 | return ( 9 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /components/forms/control-input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, Ref } from 'react'; 2 | import { InputProps, joinInputStyles } from './Input'; 3 | 4 | function ControlInput( 5 | { mode = 'default', label, type = 'text', size = 'm', className = '', disabled = false, ...props }: InputProps, 6 | ref: Ref 7 | ) { 8 | return ( 9 | 17 | ); 18 | } 19 | 20 | export default forwardRef(ControlInput); 21 | -------------------------------------------------------------------------------- /lib/auth/guest-only.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import LoadingSpinner from '../../components/common/LoadingSpinner'; 3 | import { useCurrentUser } from '../hooks/use-current-user'; 4 | import { useReplace } from '../hooks/use-router-methods'; 5 | 6 | export default function guestOnly(Component: any) { 7 | const GuestComponent = () => { 8 | const replace = useReplace(); 9 | const { user, loading } = useCurrentUser(); 10 | useEffect(() => { 11 | if (!loading && user) replace('/'); 12 | }, [user, loading, replace]); 13 | if (loading) return ; 14 | return !user ? : ; 15 | }; 16 | return GuestComponent; 17 | } 18 | -------------------------------------------------------------------------------- /lib/schemas/auth.graphql: -------------------------------------------------------------------------------- 1 | mutation Login($input: UserLoginInput!) { 2 | login(input: $input) { 3 | id 4 | username 5 | email 6 | bio 7 | image 8 | token 9 | } 10 | } 11 | 12 | mutation Signup($input: UserSignupInput!) { 13 | signup(input: $input) { 14 | id 15 | username 16 | email 17 | bio 18 | image 19 | token 20 | } 21 | } 22 | 23 | query CurrentUser { 24 | currentUser { 25 | id 26 | username 27 | email 28 | bio 29 | image 30 | } 31 | } 32 | 33 | query CheckUsername($username: String!) { 34 | checkUsername(username: $username) 35 | } 36 | 37 | query CheckEmail($email: String!) { 38 | checkEmail(email: $email) 39 | } 40 | -------------------------------------------------------------------------------- /lib/seo/index.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from '../constants'; 2 | 3 | const defaultSeo = { 4 | defaultTitle: 'Conduit', 5 | titleTemplate: '%s | Conduit', 6 | description: 'A fullstack implementation of the RealWorld App using Next.js, Prisma ORM and Apollo GraphQL stack', 7 | openGraph: { 8 | type: 'website', 9 | title: 'Conduit', 10 | description: 'A fullstack implementation of the RealWorld App using Next.js, Prisma ORM and Apollo GraphQL stack', 11 | site_name: 'next-real-world', 12 | url: BASE_URL, 13 | }, 14 | twitter: { 15 | handle: '@handle', 16 | site: '@site', 17 | cardType: 'summary_large_image', 18 | }, 19 | }; 20 | 21 | export default defaultSeo; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"], 20 | "ts-node": { 21 | "compilerOptions": { 22 | "module": "commonjs" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /components/common/Footer.tsx: -------------------------------------------------------------------------------- 1 | import CustomLink from './CustomLink'; 2 | 3 | export default function Footer() { 4 | return ( 5 |
6 |
7 | 8 | conduit 9 | 10 | 11 | An interactive learning project from Thinkster. Code 12 | & design licensed under MIT. 13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /components/forms/TextAreaInput.tsx: -------------------------------------------------------------------------------- 1 | import { CommonInputProps, joinInputStyles } from './CustomInput'; 2 | 3 | interface TextAreaProps extends CommonInputProps { 4 | rows: number; 5 | onChange: (ev: React.ChangeEvent) => void; 6 | } 7 | 8 | export default function TextAreaInput({ 9 | placeholder, 10 | disabled, 11 | value, 12 | size = 'l', 13 | mode = 'default', 14 | className, 15 | rows, 16 | onChange, 17 | }: TextAreaProps) { 18 | return ( 19 | <> 20 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import CustomLink from '../components/common/CustomLink'; 3 | import Wrapper from '../components/common/wrapper'; 4 | 5 | const Custom404: NextPage = () => { 6 | return ( 7 | 8 |
9 |

404

10 |

11 | 12 | Go To Home Page 13 | 14 |

15 |

Sorry, the content you are looking for could not be found.

16 |
17 |
18 | ); 19 | }; 20 | 21 | export default Custom404; 22 | -------------------------------------------------------------------------------- /components/common/Errors.tsx: -------------------------------------------------------------------------------- 1 | export type GenericErrors = Record | string[] | string; 2 | 3 | export default function Errors({ errors }: { errors: GenericErrors }) { 4 | return ( 5 |
    6 | {Array.isArray(errors) ? ( 7 |
  • {errors[0]}
  • 8 | ) : typeof errors === 'string' ? ( 9 |
  • {errors}
  • 10 | ) : ( 11 | Object.entries(errors).map(([field, fieldErrors]) => 12 | fieldErrors.map((fieldError) => ( 13 |
  • 14 | {field} {fieldError} 15 |
  • 16 | )) 17 | ) 18 | )} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /lib/api/query/tag.query.ts: -------------------------------------------------------------------------------- 1 | import { extendType } from 'nexus'; 2 | import { SIDEBAR_TAG_QUERY_SIZE } from '../../constants'; 3 | import { Context } from '../context'; 4 | 5 | const TagQuery = extendType({ 6 | type: 'Query', 7 | definition(t) { 8 | t.nonNull.list.nonNull.string('tags', { 9 | resolve: async (_, _args, context: Context) => { 10 | const tags = await context.prisma.tag.findMany({ 11 | select: { 12 | name: true, 13 | _count: { select: { articles: true } }, 14 | }, 15 | orderBy: { articles: { _count: 'desc' } }, 16 | skip: 0, 17 | take: SIDEBAR_TAG_QUERY_SIZE, 18 | }); 19 | return tags.filter((t) => t._count.articles != 0).map((t) => t.name); 20 | }, 21 | }); 22 | }, 23 | }); 24 | 25 | export default TagQuery; 26 | -------------------------------------------------------------------------------- /lib/hooks/use-router-methods.ts: -------------------------------------------------------------------------------- 1 | import type { NextRouter } from 'next/router'; 2 | import { useRouter } from 'next/router'; 3 | import { useRef, useState } from 'react'; 4 | 5 | export function usePush(): NextRouter['push'] { 6 | const router = useRouter(); 7 | const routerRef = useRef(router); 8 | 9 | routerRef.current = router; 10 | 11 | const [{ push }] = useState>({ 12 | push: (path) => routerRef.current.push(path), 13 | }); 14 | return push; 15 | } 16 | 17 | export function useReplace(): NextRouter['replace'] { 18 | const router = useRouter(); 19 | const routerRef = useRef(router); 20 | 21 | routerRef.current = router; 22 | 23 | const [{ replace }] = useState>({ 24 | replace: (path) => routerRef.current.replace(path), 25 | }); 26 | return replace; 27 | } 28 | -------------------------------------------------------------------------------- /components/forms/textarea.tsx: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, forwardRef, Ref, TextareaHTMLAttributes } from 'react'; 2 | import { InputMode, InputSize, joinInputStyles } from './Input'; 3 | 4 | export type TextareaProps = Partial<{ 5 | label: string; 6 | size: InputSize; 7 | mode: InputMode; 8 | }> & 9 | DetailedHTMLProps, HTMLTextAreaElement>; 10 | 11 | function Textarea( 12 | { mode = 'default', label, size = 'm', className = '', disabled = false, ...props }: TextareaProps, 13 | ref: Ref 14 | ) { 15 | return ( 16 | 53 | 54 | ); 55 | } 56 | 57 | export function ListFormGroup({ 58 | type, 59 | placeholder, 60 | disabled, 61 | value, 62 | listValue, 63 | lg, 64 | onChange, 65 | onEnter, 66 | onRemoveItem, 67 | }: { 68 | type: string; 69 | placeholder: string; 70 | disabled: boolean; 71 | value: string; 72 | listValue: string[]; 73 | lg: boolean; 74 | onChange: (ev: React.ChangeEvent) => void; 75 | onEnter: () => void; 76 | onRemoveItem: (index: number) => void; 77 | }) { 78 | return ( 79 |
80 | ev.key === 'Enter' && ev.preventDefault()} 84 | onKeyUp={onListFieldKeyUp(onEnter)} 85 | autoComplete={`${type === 'password' ? 'off' : 'on'}`} 86 | /> 87 |
88 | {listValue.map((value, index) => ( 89 | onRemoveItem(index)}> 90 | 91 | {value} 92 | 93 | ))} 94 |
95 |
96 | ); 97 | } 98 | 99 | export function onListFieldKeyUp(onEnter: () => void): (ev: React.KeyboardEvent) => void { 100 | return (ev) => { 101 | if (ev.key === 'Enter') { 102 | ev.preventDefault(); 103 | onEnter(); 104 | } 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /generated/schema.graphql: -------------------------------------------------------------------------------- 1 | ### This file was generated by Nexus Schema 2 | ### Do not make changes to this file directly 3 | 4 | 5 | type Article implements Node { 6 | author: Profile! 7 | body: String! 8 | createdAt: DateTime! 9 | description: String! 10 | favorited: Boolean! 11 | favoritesCount: Int! 12 | id: Int! 13 | slug: String! 14 | tagList: [String!]! 15 | title: String! 16 | updatedAt: DateTime! 17 | } 18 | 19 | input ArticleInput { 20 | body: String! 21 | description: String! 22 | tagList: [String!]! 23 | title: String! 24 | } 25 | 26 | type AuthUser implements BaseUser & Node { 27 | bio: String 28 | email: String! 29 | id: Int! 30 | image: String 31 | token: String 32 | username: String! 33 | } 34 | 35 | interface BaseUser { 36 | bio: String 37 | image: String 38 | username: String! 39 | } 40 | 41 | type Comment implements Node { 42 | author: Profile! 43 | body: String! 44 | createdAt: DateTime! 45 | id: Int! 46 | updatedAt: DateTime! 47 | } 48 | 49 | input CommentInput { 50 | body: String! 51 | } 52 | 53 | """ 54 | A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. 55 | """ 56 | scalar DateTime 57 | 58 | type Mutation { 59 | createArticle(input: ArticleInput!): Article! 60 | createComment(input: CommentInput!, slug: String!): Comment! 61 | deleteArticle(slug: String!): Article! 62 | deleteComment(id: Int!): Comment! 63 | favorite(slug: String!): Article! 64 | follow(username: String!): Profile! 65 | login(input: UserLoginInput!): AuthUser! 66 | signup(input: UserSignupInput!): AuthUser 67 | unFollow(username: String!): Profile! 68 | unfavorite(slug: String!): Article! 69 | updateArticle(input: ArticleInput!, slug: String!): Article! 70 | updateUser(input: UserUpdateInput!): AuthUser! 71 | } 72 | 73 | interface Node { 74 | id: Int! 75 | } 76 | 77 | type Profile implements BaseUser { 78 | bio: String 79 | following: Boolean! 80 | image: String 81 | username: String! 82 | } 83 | 84 | type Query { 85 | article(slug: String!): Article 86 | articles(author: String, cursor: Int, favorited: String, limit: Int = 10, offset: Int = 0, tag: String): [Article!]! 87 | articlesCount(author: String, favorited: String, tag: String): Int! 88 | checkEmail(email: String!): String 89 | checkUsername(username: String!): String 90 | comments(articleId: Int!, cursor: Int, limit: Int = 20, offset: Int = 0): [Comment!]! 91 | currentUser: AuthUser! 92 | feed(cursor: Int, limit: Int = 10, offset: Int = 0): [Article!]! 93 | feedCount: Int! 94 | profile(username: String!): Profile 95 | tags: [String!]! 96 | } 97 | 98 | input UserLoginInput { 99 | email: String! 100 | password: String! 101 | } 102 | 103 | input UserSignupInput { 104 | email: String! 105 | password: String! 106 | username: String! 107 | } 108 | 109 | input UserUpdateInput { 110 | bio: String 111 | email: String! 112 | image: String 113 | password: String 114 | username: String! 115 | } -------------------------------------------------------------------------------- /components/common/GenericForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { GenericFormField } from '../../lib/utils/genericFormField'; 3 | import CustomButton from './CustomButton'; 4 | import Errors, { GenericErrors } from './Errors'; 5 | import { FormGroup, ListFormGroup, TextAreaFormGroup } from './FormGroup'; 6 | 7 | export interface GenericFormProps { 8 | fields: GenericFormField[]; 9 | disabled: boolean; 10 | formObject: Record; 11 | submitButtonText: string; 12 | errors: GenericErrors; 13 | onChange: (name: string, value: string) => void; 14 | onSubmit: (ev: React.FormEvent) => void; 15 | onAddItemToList?: (name: string) => void; 16 | onRemoveListItem?: (name: string, index: number) => void; 17 | } 18 | 19 | export const GenericForm: FC = ({ 20 | fields, 21 | disabled, 22 | formObject, 23 | submitButtonText, 24 | errors, 25 | onChange, 26 | onSubmit, 27 | onAddItemToList, 28 | onRemoveListItem, 29 | }) => ( 30 | 31 | 32 | 33 |
34 |
35 | {fields.map((field) => 36 | field.fieldType === 'input' ? ( 37 | 46 | ) : field.fieldType === 'textarea' ? ( 47 | 57 | ) : ( 58 | onAddItemToList && field.listName && onAddItemToList(field.listName)} 67 | onRemoveItem={(index) => onRemoveListItem && field.listName && onRemoveListItem(field.listName, index)} 68 | lg={field.lg} 69 | /> 70 | ) 71 | )} 72 | {/* */} 73 | 74 | {submitButtonText} 75 | 76 |
77 |
78 |
79 | ); 80 | 81 | function onUpdateField( 82 | name: string, 83 | onChange: GenericFormProps['onChange'] 84 | ): (ev: React.ChangeEvent) => void { 85 | return ({ target: { value } }) => { 86 | onChange(name, value); 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /components/editor/ArticleEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import * as R from 'ramda'; 3 | import { 4 | ArticleInput, 5 | ArticlesDocument, 6 | AuthUser, 7 | EditArticleViewFragment, 8 | TagsDocument, 9 | useCreateArticleMutation, 10 | useUpdateArticleMutation, 11 | } from '../../generated/graphql'; 12 | import { ARTICLES_PAGE_SIZE } from '../../lib/constants'; 13 | import { useMessageHandler } from '../../lib/hooks/use-message'; 14 | import { articleInputSchema } from '../../lib/validation/schema'; 15 | import Form from '../forms/form'; 16 | import FormTextarea from '../forms/form-teextarea'; 17 | import FormInput from '../forms/FormInput'; 18 | import Submit from '../forms/submit'; 19 | import TagInput from '../forms/tag-input'; 20 | 21 | export default function ArticleEditor({ article, user }: { article?: EditArticleViewFragment; user: AuthUser }) { 22 | const router = useRouter(); 23 | const { handleErrors } = useMessageHandler(); 24 | 25 | const [createArticle, { loading: createSubmitting }] = useCreateArticleMutation({ 26 | refetchQueries: [ 27 | { query: TagsDocument }, 28 | { query: ArticlesDocument, variables: { offset: 0, limit: ARTICLES_PAGE_SIZE } }, 29 | { query: ArticlesDocument, variables: { author: user.username, offset: 0, limit: ARTICLES_PAGE_SIZE } }, 30 | ], 31 | onCompleted: (data) => { 32 | if (data) router.replace(`/article/${data.createArticle.slug}`); 33 | }, 34 | onError: (err) => handleErrors({ err, mode: 'alert' }), 35 | }); 36 | const [updateArticle, { loading: updateSubmitting }] = useUpdateArticleMutation({ 37 | refetchQueries: [{ query: TagsDocument }], 38 | onCompleted: (data) => { 39 | if (data) router.replace(`/article/${data.updateArticle.slug}`); 40 | }, 41 | onError: (err) => handleErrors({ err, mode: 'alert' }), 42 | }); 43 | 44 | async function onSubmit(input: ArticleInput) { 45 | article 46 | ? await updateArticle({ variables: { slug: article.slug, input } }) 47 | : await createArticle({ variables: { input } }); 48 | } 49 | 50 | const init = article 51 | ? R.pickAll(['body', 'description', 'title', 'tagList'], article) 52 | : { body: '', description: '', title: '', tagList: [] }; 53 | 54 | return ( 55 |
56 |
57 |
58 | onSubmit={onSubmit} schema={articleInputSchema} mode='onChange' defaultValues={init}> 59 |
60 | name='title' placeholder='Article title' /> 61 | name='description' placeholder="What's this article about?" /> 62 | name='body' placeholder='Write your article (in markdown)' rows={8} /> 63 | name='tagList' placeholder='Enter tags' /> 64 | 65 | 66 | Publish Article 67 | 68 |
69 | 70 |
71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-real-world", 3 | "version": "0.1.0", 4 | "description": "A fullstack implementation of the RealWorld App using Next.js, Prisma ORM and Apollo GraphQL stack", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "deploy": "sls --debug", 12 | "db:migrate": "prisma migrate dev", 13 | "generate:prisma": "prisma generate", 14 | "generate:nexus": "ts-node --transpile-only lib/api/schema", 15 | "generate:jwk": "node gen-jwk", 16 | "generate:graphql": "graphql-codegen --config .graphql-codegen.yml", 17 | "build:anl": "ANALYZE=true next build" 18 | }, 19 | "dependencies": { 20 | "@apollo/client": "^3.6.9", 21 | "@graphql-codegen/cli": "^2.7.0", 22 | "@graphql-codegen/typescript": "2.6.0", 23 | "@graphql-codegen/typescript-operations": "2.4.3", 24 | "@graphql-codegen/typescript-react-apollo": "3.2.17", 25 | "@hookform/resolvers": "^2.9.6", 26 | "@prisma/client": "^4.0.0", 27 | "@react-spring/web": "^9.5.2", 28 | "apollo-server-micro": "^3.9.0", 29 | "bcryptjs": "^2.4.3", 30 | "crypto-hash": "^2.0.1", 31 | "date-fns": "^2.28.0", 32 | "get-user-locale": "^1.4.0", 33 | "graphql": "^16.5.0", 34 | "graphql-scalars": "^1.17.0", 35 | "highlight.js": "^11.6.0", 36 | "jsonwebtoken": "^8.5.1", 37 | "jwk-to-pem": "^2.0.5", 38 | "marked": "^4.0.18", 39 | "micro": "^9.3.4", 40 | "micro-cors": "^0.1.1", 41 | "next": "12.2.0", 42 | "next-seo": "^5.5.0", 43 | "nexus": "^1.3.0", 44 | "nexus-validate": "^1.2.0", 45 | "ramda": "^0.28.0", 46 | "react": "18.2.0", 47 | "react-dom": "18.2.0", 48 | "react-hook-form": "^7.33.1", 49 | "slug": "^5.3.0", 50 | "usehooks-ts": "^2.6.0", 51 | "yup": "^0.32.11" 52 | }, 53 | "devDependencies": { 54 | "@next/bundle-analyzer": "^12.2.3", 55 | "@sls-next/aws-cloudfront": "^3.7.0", 56 | "@sls-next/aws-lambda": "^3.7.0", 57 | "@sls-next/aws-sqs": "^3.7.0", 58 | "@sls-next/domain": "^3.7.0", 59 | "@sls-next/serverless-component": "^3.7.0", 60 | "@tailwindcss/typography": "^0.5.4", 61 | "@types/bcryptjs": "^2.4.2", 62 | "@types/crypto-hash": "^1.1.2", 63 | "@types/date-fns": "^2.6.0", 64 | "@types/highlight.js": "^10.1.0", 65 | "@types/jsonwebtoken": "^8.5.8", 66 | "@types/jwk-to-pem": "^2.0.1", 67 | "@types/marked": "^4.0.3", 68 | "@types/micro-cors": "^0.1.2", 69 | "@types/node": "18.0.0", 70 | "@types/ramda": "^0.28.14", 71 | "@types/react": "18.0.14", 72 | "@types/react-dom": "18.0.5", 73 | "@types/slug": "^5.0.3", 74 | "@typescript-eslint/eslint-plugin": "^5.29.0", 75 | "@typescript-eslint/parser": "^5.29.0", 76 | "autoprefixer": "^10.4.7", 77 | "cssnano": "^5.1.12", 78 | "eslint": "8.19.0", 79 | "eslint-config-next": "12.2.0", 80 | "eslint-config-prettier": "^8.5.0", 81 | "eslint-plugin-prettier": "^4.0.0", 82 | "jose": "^4.8.3", 83 | "postcss": "^8.4.14", 84 | "prettier": "^2.7.1", 85 | "prisma": "^4.0.0", 86 | "rimraf": "^3.0.2", 87 | "serverless": "2.72.2", 88 | "tailwindcss": "^3.1.6", 89 | "ts-node": "10.8.1", 90 | "typescript": "4.7.4" 91 | }, 92 | "peerDependencies": { 93 | "graphql": "^16.5.0", 94 | "react": "18.2.0", 95 | "react-dom": "18.2.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | binaryTargets = ["native", "rhel-openssl-1.0.x"] 7 | } 8 | 9 | datasource db { 10 | provider = "mysql" 11 | url = env("DATABASE_URL") 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | bio String? 17 | email String @unique 18 | image String? 19 | password String 20 | username String @unique 21 | favorites Favorites[] 22 | followedBy Follows[] @relation("following") 23 | following Follows[] @relation("follower") 24 | comments Comment[] 25 | articles Article[] 26 | 27 | @@map("blog_user") 28 | } 29 | 30 | model Follows { 31 | follower User @relation("follower", fields: [followerId], references: [id]) 32 | followerId Int @map("follower_id") 33 | following User @relation("following", fields: [followingId], references: [id]) 34 | followingId Int @map("following_id") 35 | 36 | @@id([followerId, followingId]) 37 | @@map("blog_follows") 38 | } 39 | 40 | model Article { 41 | id Int @id @default(autoincrement()) 42 | slug String @unique 43 | title String 44 | description String 45 | body String @db.Text 46 | createdAt DateTime @default(now()) @map("created_at") 47 | updatedAt DateTime @default(now()) @map("updated_at") 48 | tags ArticlesTags[] 49 | author User @relation(fields: [authorId], references: [id]) 50 | authorId Int @map("author_id") 51 | favoritedBy Favorites[] 52 | comments Comment[] 53 | del Boolean @default(false) 54 | favoritesCount Int @default(0) @map("favorites_count") 55 | 56 | @@map("blog_article") 57 | } 58 | 59 | model Favorites { 60 | favoriting Article @relation(fields: [articleId], references: [id]) 61 | articleId Int @map("article_id") 62 | favoritedBy User @relation(fields: [userId], references: [id]) 63 | userId Int @map("user_id") 64 | 65 | @@id([articleId, userId]) 66 | @@map("blog_favorites") 67 | } 68 | 69 | model Comment { 70 | id Int @id @default(autoincrement()) 71 | createdAt DateTime @default(now()) @map("created_at") 72 | updatedAt DateTime @default(now()) @map("updated_at") 73 | body String @db.VarChar(1024) 74 | article Article @relation(fields: [articleId], references: [id]) 75 | articleId Int @map("article_id") 76 | author User @relation(fields: [authorId], references: [id]) 77 | authorId Int @map("author_id") 78 | del Boolean @default(false) 79 | 80 | @@map("blog_comment") 81 | } 82 | 83 | model ArticlesTags { 84 | article Article @relation(fields: [articleId], references: [id]) 85 | articleId Int @map("article_id") 86 | tag Tag @relation(fields: [tagId], references: [id]) 87 | tagId Int @map("tag_id") 88 | 89 | @@id([articleId, tagId]) 90 | @@map("blog_articles_tags") 91 | } 92 | 93 | model Tag { 94 | id Int @id @default(autoincrement()) 95 | name String @unique 96 | articles ArticlesTags[] 97 | 98 | @@map("blog_tag") 99 | } 100 | -------------------------------------------------------------------------------- /pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { useApolloClient } from '@apollo/client'; 2 | import CustomButton from '../components/common/CustomButton'; 3 | import Wrapper from '../components/common/wrapper'; 4 | import Form from '../components/forms/form'; 5 | import FormTextarea from '../components/forms/form-teextarea'; 6 | import FormInput from '../components/forms/FormInput'; 7 | import Submit from '../components/forms/submit'; 8 | import { AuthUser, ProfileDocument, UserUpdateInput, useUpdateUserMutation } from '../generated/graphql'; 9 | import withAuth from '../lib/auth/with-auth'; 10 | import { useMessageHandler } from '../lib/hooks/use-message'; 11 | import { useToken } from '../lib/hooks/use-token'; 12 | import { useCheckUser } from '../lib/hooks/use-validation'; 13 | 14 | const Settings = ({ user }: { user: AuthUser }) => { 15 | const client = useApolloClient(); 16 | const { handleChangeToken } = useToken(); 17 | const { success, handleErrors } = useMessageHandler(); 18 | 19 | const [updateUser, { loading }] = useUpdateUserMutation({ 20 | refetchQueries: [{ query: ProfileDocument, variables: { username: user?.username } }], 21 | onError: (err) => handleErrors({ err, mode: 'alert' }), 22 | onCompleted: () => success({ content: 'user updated!', mode: 'alert' }), 23 | }); 24 | 25 | async function onUpdateSettings(input: UserUpdateInput) { 26 | await updateUser({ variables: { input } }); 27 | } 28 | 29 | function onLogout() { 30 | return async () => { 31 | handleChangeToken(''); 32 | await client.resetStore(); 33 | }; 34 | } 35 | 36 | const checkSchema = useCheckUser(user); 37 | const { username, email, bio, image } = user; 38 | const init: UserUpdateInput = { username, email, bio: bio ?? '', image: image ?? '', password: '' }; 39 | return ( 40 | 41 |
42 |

Your Settings

43 |
44 | 45 | onSubmit={onUpdateSettings} 46 | schema={checkSchema} 47 | mode='onBlur' 48 | reValidateMode='onBlur' 49 | defaultValues={init} 50 | > 51 |
52 | 53 | name='image' 54 | placeholder='URL of profile picture(currently support i.imgur.com)' 55 | watch 56 | /> 57 | name='username' placeholder='Your name' /> 58 | name='bio' placeholder='Short bio about you' rows={8} /> 59 | name='email' placeholder='Email' /> 60 | name='password' placeholder='Password' type='password' clear /> 61 | 62 | 63 | Update Settings 64 | 65 |
66 | 67 | 68 |
69 | 70 | 71 | Or click here to logout.() 72 | 73 |
74 |
75 |
76 | ); 77 | }; 78 | 79 | export default withAuth(Settings); 80 | -------------------------------------------------------------------------------- /components/article/CommentSection.tsx: -------------------------------------------------------------------------------- 1 | import { NetworkStatus } from '@apollo/client'; 2 | import { useCallback, useState } from 'react'; 3 | import { ArticleViewFragment, useCommentsQuery } from '../../generated/graphql'; 4 | import { COMMENTS_PAGE_SIZE } from '../../lib/constants'; 5 | import { useCurrentUser } from '../../lib/hooks/use-current-user'; 6 | import { useMessageHandler } from '../../lib/hooks/use-message'; 7 | import CustomLink from '../common/CustomLink'; 8 | import LoadingSpinner from '../common/LoadingSpinner'; 9 | import LoadMore from '../common/LoadMore'; 10 | import ArticleComment from './ArticleComment'; 11 | import CommentForm from './CommentForm'; 12 | 13 | export default function CommentSection({ article }: { article: ArticleViewFragment }) { 14 | const { user, loading: userLoading } = useCurrentUser(); 15 | const { message, error, info } = useMessageHandler(); 16 | 17 | const fallbackMessage = 'Could not load comments... '; 18 | const noArticlesMessage = 'No comments here... yet'; 19 | const { data, fetchMore, networkStatus } = useCommentsQuery({ 20 | variables: { articleId: article.id, offset: 0, limit: COMMENTS_PAGE_SIZE }, 21 | fetchPolicy: 'cache-and-network', 22 | nextFetchPolicy: 'cache-first', 23 | notifyOnNetworkStatusChange: true, 24 | onError: () => error({ content: fallbackMessage, mode: 'toast' }), 25 | onCompleted: (data) => { 26 | if (data && data.comments.length === 0) info({ content: noArticlesMessage, mode: 'none' }); 27 | }, 28 | }); 29 | 30 | const comments = data?.comments; 31 | const last = comments && comments.length && comments[data.comments.length - 1].id; 32 | const loading = networkStatus === NetworkStatus.loading; 33 | const loadMoreLoading = networkStatus === NetworkStatus.fetchMore; 34 | 35 | const [fetchedSize, setFetchedSize] = useState(COMMENTS_PAGE_SIZE); 36 | const onLoadMore = useCallback( 37 | async ({ offset, cursor }: { offset: number; cursor: number }) => { 38 | const { data } = await fetchMore({ 39 | variables: { articleId: article.id, offset, cursor, limit: COMMENTS_PAGE_SIZE }, 40 | }); 41 | setFetchedSize(data.comments.length); 42 | }, 43 | [article, fetchMore] 44 | ); 45 | return ( 46 |
47 |
48 |
49 | {!userLoading && ( 50 | <> 51 | {user ? ( 52 | 53 | ) : ( 54 |

55 | 56 | Sign in 57 | {' '} 58 | or{' '} 59 | 60 | sign up 61 | {' '} 62 | to add comments on this article. 63 |

64 | )} 65 | 66 | )} 67 | {loading && } 68 | 69 |
    70 | {data?.comments.map((comment) => ( 71 |
  • 72 | 73 |
  • 74 | ))} 75 |
76 | 77 |
78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /components/common/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | import { useState } from 'react'; 3 | import { useCurrentUser } from '../../lib/hooks/use-current-user'; 4 | import CustomLink from './CustomLink'; 5 | import NavItem, { NavItemProps } from './NavItem'; 6 | 7 | export default function Header() { 8 | const { user, loading } = useCurrentUser(); 9 | const [ariaExpanded, setAriaExpanded] = useState(false); 10 | function toggleExpand(): () => void { 11 | return () => setAriaExpanded(!ariaExpanded); 12 | } 13 | 14 | const commonNavItems: NavItemProps[] = [{ text: 'Home', href: '/' }]; 15 | const guestNavItems: NavItemProps[] = [ 16 | { text: 'Sign in', href: '/login' }, 17 | { text: 'Sign up', href: '/register' }, 18 | ]; 19 | const userNavItems: NavItemProps[] = [ 20 | { text: 'New Article', href: '/editor', icon: 'ion-compose' }, 21 | { text: 'Settings', href: '/settings', icon: 'ion-gear-a' }, 22 | { text: `${user?.username}`, href: `/profile/${user?.username}` }, 23 | ]; 24 | return ( 25 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /lib/api/mutation/user.mutation.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client'; 2 | import { UserInputError } from 'apollo-server-micro'; 3 | import { arg, mutationType, nonNull } from 'nexus'; 4 | import { loginInputSchema, signupInputSchema, updateUserInputSchema } from '../../validation/schema'; 5 | import { Context } from '../context'; 6 | import Utility from '../utils'; 7 | 8 | const UserMutation = mutationType({ 9 | definition(t) { 10 | t.nonNull.field('login', { 11 | type: 'AuthUser', 12 | args: { 13 | input: nonNull(arg({ type: 'UserLoginInput' })), 14 | }, 15 | validate: () => ({ 16 | input: loginInputSchema, 17 | }), 18 | resolve: async (_, { input: { email, password: inputPassword } }, context: Context) => { 19 | const user = await context.prisma.user.findUnique({ where: { email } }); 20 | if (!user) throw new UserInputError('Bad Credentials'); 21 | 22 | const { id, username, password } = user; 23 | const checkPassword = Utility.checkPassword(inputPassword, password); 24 | if (!checkPassword) throw new UserInputError('Bad Credentials'); 25 | 26 | const payload = { sub: id, user: username }; 27 | return { ...user, token: Utility.issueToken(payload) }; 28 | }, 29 | }); 30 | t.nullable.field('signup', { 31 | type: 'AuthUser', 32 | args: { 33 | input: nonNull(arg({ type: 'UserSignupInput' })), 34 | }, 35 | validate: () => ({ 36 | input: signupInputSchema, 37 | }), 38 | resolve: async (_, { input }, context: Context) => { 39 | try { 40 | const { password, ...inputRest } = input; 41 | const user = await context.prisma.user.create({ 42 | data: { 43 | ...inputRest, 44 | password: Utility.encodePassword(password), 45 | }, 46 | }); 47 | const { id, username } = user; 48 | const payload = { sub: id, user: username }; 49 | return { ...user, token: Utility.issueToken(payload) }; 50 | } catch (e) { 51 | if (e instanceof Prisma.PrismaClientKnownRequestError) { 52 | if (e.code === 'P2002') throw new UserInputError('Username or email had been used'); 53 | } 54 | return null; 55 | } 56 | }, 57 | }); 58 | t.nonNull.field('updateUser', { 59 | type: 'AuthUser', 60 | args: { 61 | input: nonNull(arg({ type: 'UserUpdateInput' })), 62 | }, 63 | authorize: (_, _args, ctx: Context) => !!ctx.currentUser, 64 | validate: () => ({ 65 | input: updateUserInputSchema, 66 | }), 67 | resolve: async (_, { input }, context: Context) => { 68 | const origin = await context.prisma.user.findUnique({ 69 | where: { id: context.currentUser!.id }, 70 | }); 71 | if (!origin) throw new UserInputError('User not found'); 72 | 73 | try { 74 | const { password, ...rest } = input; 75 | return await context.prisma.user.update({ 76 | where: { id: origin.id }, 77 | data: { 78 | ...rest, 79 | // change password 80 | password: !!password ? Utility.encodePassword(password) : undefined, 81 | }, 82 | }); 83 | } catch (e) { 84 | if (e instanceof Prisma.PrismaClientKnownRequestError) { 85 | if (e.code === 'P2002') throw new UserInputError('Username or email had been used'); 86 | } 87 | return origin; 88 | } 89 | }, 90 | }); 91 | }, 92 | }); 93 | 94 | export default UserMutation; 95 | -------------------------------------------------------------------------------- /components/forms/CustomForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CustomButton from '../common/CustomButton'; 3 | import Errors, { GenericErrors } from '../common/Errors'; 4 | import CustomInput from './CustomInput'; 5 | import TagInput from './TagInput'; 6 | import TextAreaInput from './TextAreaInput'; 7 | 8 | export interface CustomFormField { 9 | name: string; 10 | type: 'text' | 'password' | 'email'; 11 | placeholder: string; 12 | rows?: number; 13 | fieldType: 'input' | 'textarea' | 'list'; 14 | listName?: string; 15 | size: 's' | 'm' | 'l'; 16 | } 17 | 18 | export interface CustomFormProps { 19 | fields: CustomFormField[]; 20 | disabled: boolean; 21 | formObject: Record; 22 | submitButtonText: string; 23 | errors: GenericErrors; 24 | onChange: (name: string, value: string) => void; 25 | onSubmit: (ev: React.FormEvent) => void; 26 | onAddItemToList?: (name: string) => void; 27 | onRemoveListItem?: (name: string, index: number) => void; 28 | } 29 | 30 | export default function CustomForm({ 31 | fields, 32 | disabled, 33 | formObject, 34 | submitButtonText, 35 | errors, 36 | onChange, 37 | onSubmit, 38 | onAddItemToList, 39 | onRemoveListItem, 40 | }: CustomFormProps) { 41 | return ( 42 | <> 43 | 44 |
45 |
46 | {fields.map((field) => 47 | field.fieldType === 'input' ? ( 48 | 57 | ) : field.fieldType === 'textarea' ? ( 58 | 67 | ) : ( 68 | onAddItemToList && field.listName && onAddItemToList(field.listName)} 76 | onRemoveItem={(index) => onRemoveListItem && field.listName && onRemoveListItem(field.listName, index)} 77 | size={field.size} 78 | /> 79 | ) 80 | )} 81 | 82 | {submitButtonText} 83 | 84 |
85 |
86 | 87 | ); 88 | } 89 | 90 | export function buildCustomFormField(data: Partial & { name: string }): CustomFormField { 91 | return { 92 | type: 'text', 93 | placeholder: '', 94 | fieldType: 'input', 95 | size: 'l', 96 | ...data, 97 | }; 98 | } 99 | 100 | function onUpdateField( 101 | name: string, 102 | onChange: CustomFormProps['onChange'] 103 | ): (ev: React.ChangeEvent) => void { 104 | return ({ target: { value } }) => { 105 | onChange(name, value); 106 | }; 107 | } 108 | -------------------------------------------------------------------------------- /lib/hooks/use-message.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloError, useApolloClient } from '@apollo/client'; 2 | import { useRouter } from 'next/router'; 3 | import { createContext, useCallback, useContext, useEffect, useState } from 'react'; 4 | import { usePush } from './use-router-methods'; 5 | import { useToken } from './use-token'; 6 | 7 | export type MessageType = 'error' | 'info' | 'success'; 8 | export type NotifyType = 'alert' | 'toast' | 'none'; 9 | export interface Message { 10 | content: string; 11 | type: MessageType; 12 | mode: NotifyType; 13 | } 14 | 15 | enum ServerErrorCode { 16 | Unauthorized = 'UNAUTHENTICATED', 17 | BadUserInput = 'BAD_USER_INPUT', 18 | InternalServerError = 'INTERNAL_SERVER_ERROR', 19 | } 20 | 21 | interface MessageContext { 22 | message: Message | null; 23 | handleErrors: ({ err, mode }: { err: ApolloError; mode: NotifyType }) => void; 24 | dismiss: () => void; 25 | dismissing: boolean; 26 | success: (message: Pick) => void; 27 | error: (message: Pick) => void; 28 | info: (message: Pick) => void; 29 | } 30 | 31 | const initMessageContext: MessageContext = { 32 | message: null, 33 | handleErrors: () => {}, 34 | dismiss: () => {}, 35 | dismissing: false, 36 | success: () => {}, 37 | error: () => {}, 38 | info: () => {}, 39 | }; 40 | 41 | const messageContext = createContext(initMessageContext); 42 | 43 | export function MessageProvider({ children }: { children: React.ReactNode }) { 44 | const messageHandler = useProvideMessageHandler(); 45 | return {children}; 46 | } 47 | 48 | export const useMessageHandler = () => { 49 | return useContext(messageContext); 50 | }; 51 | 52 | function useProvideMessageHandler() { 53 | const [message, setMessage] = useState(null); 54 | const { token, handleChangeToken } = useToken(); 55 | const client = useApolloClient(); 56 | const push = usePush(); 57 | const { asPath } = useRouter(); 58 | const [dismissing, setDismissing] = useState(false); 59 | 60 | useEffect(() => { 61 | const onMount = () => { 62 | setDismissing(true); 63 | setMessage(null); // dismiss when path changed 64 | setDismissing(false); 65 | }; 66 | onMount(); 67 | return () => setDismissing(false); 68 | }, [asPath]); 69 | 70 | const success = useCallback(({ content, mode }: Pick) => { 71 | setMessage({ content, mode, type: 'success' }); 72 | }, []); 73 | 74 | const error = useCallback(({ content, mode }: Pick) => { 75 | setMessage({ content, mode, type: 'error' }); 76 | }, []); 77 | 78 | const info = useCallback(({ content, mode }: Pick) => { 79 | setMessage({ content, mode, type: 'info' }); 80 | }, []); 81 | 82 | const dismiss = useCallback(() => { 83 | setMessage(null); 84 | }, []); 85 | 86 | const handleErrors = useCallback( 87 | ({ err: { graphQLErrors, networkError }, mode }: { err: ApolloError; mode: NotifyType }) => { 88 | const reset = async () => { 89 | handleChangeToken(''); 90 | await client.resetStore(); 91 | push('/login'); 92 | }; 93 | 94 | if (graphQLErrors) { 95 | for (let err of graphQLErrors) { 96 | switch (err.extensions.code) { 97 | case ServerErrorCode.Unauthorized: 98 | if (token) reset(); // invalid token push to login 99 | break; 100 | default: 101 | setMessage({ content: err.message, mode, type: 'error' }); 102 | break; 103 | } 104 | } 105 | if (networkError) setMessage({ content: networkError.message, mode, type: 'error' }); 106 | } 107 | }, 108 | [token, handleChangeToken, push, client] 109 | ); 110 | return { message, handleErrors, dismiss, dismissing, success, info, error }; 111 | } 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-real-world 2 | 3 | ## About 4 | 5 | ## [Demo] [RealWorld] 6 | 7 | This codebase was created to demonstrate a fully fledged fullstack application built with Next.js, Apollo GraphQL stack and Prisma including CRUD operations, authentication, routing, pagination, and more. 8 | 9 | ## Used Stacks 10 | 11 | ### backend 12 | 13 | - [Prisma] ORM (v4.0 seems supports Node version 14 or later) 14 | - [Apollo Server] mounted on the API route of Next.js pages 15 | - [Nexus] as a code-first GraphQL schema generator 16 | 17 | ### frontend 18 | 19 | - [Next.js] 20 | - [Apollo Client] together with [GraphQL Code Generator] 21 | - [Tailwind CSS] 22 | 23 | ## Features 24 | 25 | - **Pagination**: use Prisma cursor-based query as well as Apollo Client `fetchMore` approach to implement a infinity load-more feature on the feed list and comment list. 26 | - **Form Validation**: including a [Yup] schema check and [React Hook Form] for frontend form validation. 27 | 28 | ## Folder Structure 29 | 30 | - `pages` All pages of a regular Next.js app. 31 | - `pages/api/index.ts` The API route, mounting a GraphQL server instance within a Prisma client. 32 | - `components` Contains all page components. 33 | - `generated` Codes generated by Nexus and GraphQL Code Generator. 34 | - `lib/api` Schema definitions of the GraphQL server. 35 | - `lib/api/prisma.ts` Prisma client configs here. 36 | - `lib/cache/index.ts` Cache config of Apollo client. 37 | - `lib/schemas` Schema definitions for the frontend. 38 | - `lib/constants.ts` App configs init. 39 | - `prisma/schema.prisma` Definitions of database. 40 | - `config.js` Build configs used in `next.config.original.js`. 41 | 42 | ## Getting Started 43 | 44 | First, install dependencies 45 | 46 | ```bash 47 | yarn 48 | ``` 49 | 50 | And setup your owner MySQL server config in a `.env` file(see example in `.env.example`) 51 | 52 | ```env 53 | DATABASE_URL="mysql://root:pass@localhost/real_blog?useUnicode=true&characterEncoding=utf8&useSSL=false" 54 | ``` 55 | 56 | Then run a Prisma migration script on your database 57 | 58 | ```bash 59 | yarn db:migrate 60 | ``` 61 | 62 | Next, generate a JWK key pairs for the JWT based authentication, this script just print out the key pairs in the console 63 | 64 | ```bash 65 | yarn generate:jwk 66 | ``` 67 | 68 | You should put the new generated PRIVATE_JWK into the `.env` file and the PUBLIC_JWK to `lib/constants.ts` as below 69 | 70 | ```env 71 | PRIVATE_JWK={your_generated_private_jwk} 72 | ``` 73 | 74 | ```typescript 75 | export const PUBLIC_JWK = { 76 | kty: 'RSA' as const, 77 | ... 78 | }; 79 | ``` 80 | 81 | Last, run the common Next.js script to start 82 | 83 | ```bash 84 | yarn dev 85 | ``` 86 | 87 | Point [http://localhost:3000](http://localhost:3000) to the app's home page and [http://localhost:3000/api](http://localhost:3000/api) would redirect to Apollo Studio to show all the GraphQL API(Only accessible in a development env) 88 | 89 | ## Learn More 90 | 91 | To learn more about Next.js, take a look at the following resources: 92 | 93 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 94 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 95 | 96 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 97 | 98 | ## Deploy on Vercel 99 | 100 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 101 | 102 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 103 | 104 | [Demo]:https://next-real-world.vercel.app 105 | [RealWorld]:https://github.com/gothinkster/realworld 106 | [Prisma]:https://github.com/prisma/prisma 107 | [Apollo Server]:https://github.com/apollographql/apollo-server 108 | [Apollo Client]:https://github.com/apollographql/apollo-client 109 | [Nexus]:https://github.com/graphql-nexus/nexus 110 | [GraphQL Code Generator]:https://github.com/dotansimha/graphql-code-generator 111 | [Tailwind CSS]:https://github.com/tailwindlabs/tailwindcss 112 | [Next.js]:https://github.com/vercel/next.js 113 | [Yup]:https://github.com/jquense/yup 114 | [React Hook Form]:https://github.com/react-hook-form/react-hook-form 115 | -------------------------------------------------------------------------------- /components/article-list/ArticlesViewer.tsx: -------------------------------------------------------------------------------- 1 | import { NetworkStatus } from '@apollo/client'; 2 | import { useRouter } from 'next/router'; 3 | import * as R from 'ramda'; 4 | import React, { useCallback, useEffect, useState } from 'react'; 5 | import { ArticlesQueryVariables, useArticlesLazyQuery, useFeedLazyQuery } from '../../generated/graphql'; 6 | import { ARTICLES_PAGE_SIZE } from '../../lib/constants'; 7 | import { useMessageHandler } from '../../lib/hooks/use-message'; 8 | import ReverseLoadMore from '../common/reverse-load-more'; 9 | import { TabProps } from '../common/Tab'; 10 | import TabList from '../common/TabList'; 11 | import ArticleList from './ArticleList'; 12 | 13 | interface ArticleListProps { 14 | tabs: TabProps[]; 15 | isFeedQuery?: boolean; 16 | queryFilter: ArticlesQueryVariables; 17 | } 18 | 19 | export default function ArticlesViewer({ tabs, isFeedQuery, queryFilter }: ArticleListProps) { 20 | const { error, info } = useMessageHandler(); 21 | const { asPath } = useRouter(); 22 | 23 | const fallbackMessage = 'Could not load articles... '; 24 | const noArticlesMessage = 'No articles are here... yet'; 25 | const [loadArticles, { data: articlesData, fetchMore, networkStatus }] = useArticlesLazyQuery({ 26 | fetchPolicy: 'cache-and-network', 27 | nextFetchPolicy: 'cache-first', 28 | notifyOnNetworkStatusChange: true, 29 | onError: () => error({ content: fallbackMessage, mode: 'alert' }), 30 | onCompleted: (data) => { 31 | if (data && data.articles.length === 0) info({ content: noArticlesMessage, mode: 'alert' }); 32 | }, 33 | }); 34 | 35 | const [loadFeed, { data: feedData, fetchMore: fetchMoreFeed, networkStatus: feedNetworkStatus }] = useFeedLazyQuery({ 36 | fetchPolicy: 'cache-and-network', 37 | nextFetchPolicy: 'cache-first', 38 | notifyOnNetworkStatusChange: true, 39 | onError: () => error({ content: fallbackMessage, mode: 'alert' }), 40 | onCompleted: (data) => { 41 | if (data && data.feed.length === 0) info({ content: noArticlesMessage, mode: 'alert' }); 42 | }, 43 | }); 44 | 45 | useEffect(() => { 46 | const loadData = async () => { 47 | const pagedQueryFilter = R.mergeRight(queryFilter, { offset: 0, limit: ARTICLES_PAGE_SIZE }); 48 | isFeedQuery 49 | ? await loadFeed({ variables: { ...pagedQueryFilter } }) 50 | : await loadArticles({ variables: { ...pagedQueryFilter } }); 51 | }; 52 | loadData(); 53 | }, [isFeedQuery, queryFilter, loadFeed, loadArticles]); 54 | 55 | const articles = isFeedQuery ? feedData?.feed : articlesData?.articles; 56 | const loading = networkStatus === NetworkStatus.loading || feedNetworkStatus === NetworkStatus.loading; 57 | const first = articles && articles.length && articles[0].id; 58 | const last = articles && articles.length && articles[articles.length - 1].id; 59 | const loadMoreLoading = networkStatus === NetworkStatus.fetchMore || feedNetworkStatus === NetworkStatus.fetchMore; 60 | 61 | const [topFetchedSize, setTopFetchedSize] = useState(ARTICLES_PAGE_SIZE); 62 | const [bottomFetchedSize, setBottomFetchedSize] = useState(ARTICLES_PAGE_SIZE); 63 | const [topLoading, setTopLoading] = useState(false); 64 | const [bottomLoading, setBottomLoading] = useState(false); 65 | useEffect(() => { 66 | // reset fetched size 67 | setTopFetchedSize(ARTICLES_PAGE_SIZE); 68 | setBottomFetchedSize(ARTICLES_PAGE_SIZE); 69 | setTopLoading(false); 70 | setBottomLoading(false); 71 | }, [asPath]); 72 | const onLoadMore = useCallback( 73 | async ({ offset, cursor }: { offset: number; cursor: number }) => { 74 | const fetchMoreQueryFilter = R.mergeRight(queryFilter, { offset, cursor, limit: ARTICLES_PAGE_SIZE }); 75 | if (isFeedQuery) { 76 | offset > 0 ? setBottomLoading(true) : setTopLoading(true); 77 | const { data } = await fetchMoreFeed({ variables: fetchMoreQueryFilter }); 78 | offset > 0 ? setBottomLoading(false) : setTopLoading(false); 79 | offset > 0 ? setBottomFetchedSize(data.feed.length) : setTopFetchedSize(data.feed.length); 80 | } else { 81 | offset > 0 ? setBottomLoading(true) : setTopLoading(true); 82 | const { data } = await fetchMore({ variables: fetchMoreQueryFilter }); 83 | offset > 0 ? setBottomLoading(false) : setTopLoading(false); 84 | offset > 0 ? setBottomFetchedSize(data.articles.length) : setTopFetchedSize(data.articles.length); 85 | } 86 | }, 87 | [isFeedQuery, fetchMoreFeed, fetchMore, queryFilter] 88 | ); 89 | return ( 90 | 91 | 92 | 95 | 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /lib/api/query/article.query.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client'; 2 | import { extendType, intArg, nonNull, stringArg } from 'nexus'; 3 | import { Context } from '../context'; 4 | 5 | const ArticleQuery = extendType({ 6 | type: 'Query', 7 | definition(t) { 8 | t.field('article', { 9 | type: 'Article', 10 | args: { 11 | slug: nonNull(stringArg()), 12 | }, 13 | validate: ({ string }) => ({ 14 | slug: string().required(), 15 | }), 16 | resolve: (_, { slug }, context: Context) => { 17 | return context.prisma.article.findUnique({ where: { slug } }); 18 | }, 19 | }); 20 | t.nonNull.list.nonNull.field('articles', { 21 | type: 'Article', 22 | args: { 23 | author: stringArg(), 24 | tag: stringArg(), 25 | favorited: stringArg(), 26 | limit: intArg({ default: 10 }), 27 | offset: intArg({ default: 0 }), 28 | cursor: intArg(), 29 | }, 30 | validate: ({ string, number }) => ({ 31 | author: string(), 32 | tag: string(), 33 | favorited: string(), 34 | limit: number().integer().positive().max(100), 35 | offset: number().integer(), 36 | cursor: number().integer().positive(), 37 | }), 38 | resolve: (_, { limit, offset, ...rest }, context: Context) => { 39 | let skip, take; 40 | if (rest.cursor && limit && offset) { 41 | skip = 1; 42 | take = offset > 0 ? limit : -limit; 43 | } else { 44 | skip = offset || undefined; 45 | take = limit || undefined; 46 | } 47 | return context.prisma.article.findMany({ 48 | where: articleQueryFilter(rest), 49 | skip, 50 | take, 51 | cursor: rest.cursor ? { id: rest.cursor } : undefined, 52 | orderBy: { createdAt: 'desc' }, 53 | }); 54 | }, 55 | }); 56 | t.nonNull.int('articlesCount', { 57 | args: { 58 | author: stringArg(), 59 | tag: stringArg(), 60 | favorited: stringArg(), 61 | }, 62 | validate: ({ string }) => ({ 63 | author: string(), 64 | tag: string(), 65 | favorited: string(), 66 | }), 67 | resolve: async (_, args, context: Context) => { 68 | const idCount = await context.prisma.article.count({ 69 | select: { id: true }, 70 | where: articleQueryFilter(args), 71 | }); 72 | return idCount.id; 73 | }, 74 | }); 75 | 76 | t.nonNull.list.nonNull.field('feed', { 77 | type: 'Article', 78 | args: { 79 | limit: intArg({ default: 10 }), 80 | offset: intArg({ default: 0 }), 81 | cursor: intArg(), 82 | }, 83 | authorize: (_, _args, ctx: Context) => !!ctx.currentUser, 84 | validate: ({ number }) => ({ 85 | limit: number().integer().positive().max(100), 86 | offset: number().integer(), 87 | cursor: number().integer().positive(), 88 | }), 89 | resolve: (_, { limit, offset, cursor }, context: Context) => { 90 | let skip, take; 91 | if (cursor && limit && offset) { 92 | skip = 1; 93 | take = offset > 0 ? limit : -limit; 94 | } else { 95 | skip = offset || undefined; 96 | take = limit || undefined; 97 | } 98 | 99 | return context.prisma.article.findMany({ 100 | where: feedQueryFilter(context.currentUser!.id), 101 | skip, 102 | take, 103 | cursor: cursor ? { id: cursor } : undefined, 104 | orderBy: { createdAt: 'desc' }, 105 | }); 106 | }, 107 | }); 108 | t.nonNull.int('feedCount', { 109 | authorize: (_, _args, ctx: Context) => !!ctx.currentUser, 110 | resolve: async (_, _args, context: Context) => { 111 | const idCount = await context.prisma.article.count({ 112 | select: { id: true }, 113 | where: feedQueryFilter(context.currentUser!.id), 114 | }); 115 | return idCount.id; 116 | }, 117 | }); 118 | }, 119 | }); 120 | 121 | const articleQueryFilter = (query: any) => { 122 | return Prisma.validator()({ 123 | AND: [ 124 | { del: false }, 125 | { author: { username: query?.author } }, 126 | { tags: { some: query?.tag && { tag: { name: query.tag } } } }, 127 | { 128 | favoritedBy: { 129 | // this "some" operator somehow could not work with the nested undefined value in an "AND" array 130 | some: query?.favorited && { favoritedBy: { username: query.favorited } }, 131 | }, 132 | }, 133 | ], 134 | }); 135 | }; 136 | 137 | const feedQueryFilter = (userId: number) => { 138 | return Prisma.validator()({ 139 | del: false, 140 | author: { followedBy: { some: { followerId: userId } } }, 141 | }); 142 | }; 143 | 144 | export default ArticleQuery; 145 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | /** backend constants */ 2 | 3 | export const TOKEN_TTL = '2d'; 4 | export const TOKEN_ALG = 'RS256'; 5 | export const TOKEN_KID = 'izayoi'; 6 | export const TOKEN_PREFIX = 'Bearer '; 7 | 8 | export const PUBLIC_JWK = { 9 | kty: 'RSA' as const, 10 | n: '2n-FvDaqq3XdC3VA8478T-tdM8qGoKU56ljNO6w8u1as8XrozsL8uyINEvToQh8h4UsggRnSeO-1kvROYQqe8eVI0LrLJdQPvV9MaTpmhaqkcRr7LGi4qjaOzi3J0CPavTnHCNjFVgyvaz2_8-7G9WuH9xx-Xov02vmK-2K6Cp0HhUPat3a7w258NmxW-Obr4DOyhCi-pCLzq8eRQfJ75MWEcWk1psmsDtlfwpU0qjxaO94b5qJwzaB2NMPyrk-9V9B2UhI9P74IQAEg-eE7bwJILaOXI6p6FmfGmssi6T-Ntk1kCSktu9IyhBZZhWU8PZasKEnXmcAkjl8-6-g-hQ', 11 | e: 'AQAB', 12 | }; 13 | 14 | /** frontend constants */ 15 | 16 | export const DEFAULT_AVATAR_PLACEHOLDER = `data:image/png;base64,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`; 17 | export const DEFAULT_AVATAR = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAABmJLR0QA/wD/AP+gvaeTAAAA6UlEQVR4nO3asQnDQBBFQZ9R6p4UuGwF6skF2D1oDIvgTQHieGz00fqcx/eRy57TD7i7AqICogKiAqICogKiAqICogKiAqICogKiAqICok0/8Nrf6x8PmaJ7aBeICogKiAqICogKiAqICogKiAqICogKiAqICogKiMa3PN3jpvfILhAVEBUQFRAVEBUQFRAVEBUQFRAVEBUQFRAVEBUQrbvvcar/A4cVEBUQFRAVEBUQFRAVEBUQFRAVEBUQFRAVEBUQ8R6odE+cfn8XiAqICogKiAqICogKiAqICogKiAqICogKiAqICoh+xesa6Scaqy0AAAAASUVORK5CYII=`; 18 | 19 | export const BASE_URL = 20 | process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://next-real-world.vercel.app'; 21 | 22 | export const ARTICLES_PAGE_SIZE = 10; 23 | export const COMMENTS_PAGE_SIZE = 20; 24 | export const ARTICLES_FETCH_MORE_INTERVAL = 15; // seconds 25 | export const COMMENTS_FETCH_MORE_INTERVAL = 15; 26 | 27 | export const SIDEBAR_TAG_QUERY_SIZE = 27; 28 | -------------------------------------------------------------------------------- /lib/api/mutation/article.mutation.ts: -------------------------------------------------------------------------------- 1 | import { Article, Prisma } from '@prisma/client'; 2 | import { AuthenticationError, UserInputError } from 'apollo-server-micro'; 3 | import { arg, extendType, nonNull, stringArg } from 'nexus'; 4 | import { articleInputSchema } from '../../validation/schema'; 5 | import { Context } from '../context'; 6 | import Utility from '../utils'; 7 | 8 | const ArticleMutation = extendType({ 9 | type: 'Mutation', 10 | definition(t) { 11 | t.nonNull.field('createArticle', { 12 | type: 'Article', 13 | args: { 14 | input: nonNull(arg({ type: 'ArticleInput' })), 15 | }, 16 | authorize: (_, _args, ctx: Context) => !!ctx.currentUser, 17 | validate: () => ({ 18 | input: articleInputSchema, 19 | }), 20 | resolve: (_, { input: { title, description, body, tagList } }, context: Context) => { 21 | return context.prisma.article.create({ 22 | data: { 23 | title, 24 | description, 25 | body, 26 | author: { connect: { id: context.currentUser!.id } }, 27 | slug: Utility.slugify(title), 28 | tags: { 29 | create: tagList?.map((name: string) => { 30 | return { 31 | tag: { 32 | connectOrCreate: { 33 | where: { name }, 34 | create: { name }, 35 | }, 36 | }, 37 | }; 38 | }), 39 | }, 40 | }, 41 | }); 42 | }, 43 | }); 44 | t.nonNull.field('updateArticle', { 45 | type: 'Article', 46 | args: { 47 | slug: nonNull(stringArg()), 48 | input: nonNull(arg({ type: 'ArticleInput' })), 49 | }, 50 | authorize: (_, args, ctx: Context) => !!ctx.currentUser, 51 | validate: ({ string }) => ({ 52 | slug: string().required(), 53 | input: articleInputSchema, 54 | }), 55 | resolve: async (_, { slug, input: { title, body, description, tagList } }, context: Context) => { 56 | const origin = await checkArticle(context, slug); 57 | checkArticleOwner(context, origin); 58 | const titleChanged = origin.title !== title; 59 | return context.prisma.article.update({ 60 | where: { slug }, 61 | data: { 62 | title: titleChanged ? title : undefined, 63 | description, 64 | body, 65 | tags: { 66 | // delete relation 67 | deleteMany: { articleId: origin.id }, 68 | // connect again 69 | create: tagList?.map((name: string) => { 70 | return { 71 | tag: { 72 | connectOrCreate: { 73 | where: { name }, 74 | create: { name }, 75 | }, 76 | }, 77 | }; 78 | }), 79 | }, 80 | slug: titleChanged ? Utility.slugify(title) : undefined, 81 | updatedAt: new Date(), 82 | }, 83 | }); 84 | }, 85 | }); 86 | t.nonNull.field('deleteArticle', { 87 | type: 'Article', 88 | args: { 89 | slug: nonNull(stringArg()), 90 | }, 91 | authorize: (_, args, ctx: Context) => !!ctx.currentUser, 92 | validate: ({ string }) => ({ 93 | slug: string().required(), 94 | }), 95 | resolve: async (_, { slug }, context: Context) => { 96 | const origin = await checkArticle(context, slug); 97 | checkArticleOwner(context, origin); 98 | return context.prisma.article.update({ 99 | where: { slug }, 100 | data: { 101 | del: true, 102 | tags: { deleteMany: { articleId: origin.id } }, 103 | favoritedBy: { deleteMany: { articleId: origin.id } }, 104 | comments: { 105 | updateMany: { 106 | where: { del: false }, 107 | data: { del: true }, 108 | }, 109 | }, 110 | }, 111 | }); 112 | }, 113 | }); 114 | t.nonNull.field('favorite', { 115 | type: 'Article', 116 | args: { 117 | slug: nonNull(stringArg()), 118 | }, 119 | authorize: (_, _args, ctx: Context) => !!ctx.currentUser, 120 | validate: ({ string }) => ({ 121 | slug: string().required(), 122 | }), 123 | resolve: async (_, { slug }, context: Context) => { 124 | const origin = await checkArticle(context, slug); 125 | try { 126 | return await context.prisma.article.update({ 127 | where: { id: origin.id }, 128 | data: { 129 | favoritesCount: { 130 | increment: 1, 131 | }, 132 | favoritedBy: { create: { userId: context.currentUser!.id } }, 133 | }, 134 | }); 135 | } catch (e) { 136 | if (e instanceof Prisma.PrismaClientKnownRequestError) { 137 | if (e.code === 'P2002') throw new UserInputError('Had been favorited'); 138 | } 139 | return origin; 140 | } 141 | }, 142 | }); 143 | t.nonNull.field('unfavorite', { 144 | type: 'Article', 145 | args: { 146 | slug: nonNull(stringArg()), 147 | }, 148 | authorize: (_, _args, ctx: Context) => !!ctx.currentUser, 149 | validate: ({ string }) => ({ 150 | slug: string().required(), 151 | }), 152 | resolve: async (_, { slug }, context: Context) => { 153 | const origin = await checkArticle(context, slug); 154 | try { 155 | return await context.prisma.article.update({ 156 | where: { id: origin.id }, 157 | data: { 158 | favoritesCount: { 159 | decrement: 1, 160 | }, 161 | favoritedBy: { 162 | delete: { 163 | articleId_userId: { userId: context.currentUser!.id, articleId: origin.id }, 164 | }, 165 | }, 166 | }, 167 | }); 168 | } catch (e) { 169 | if (e instanceof Prisma.PrismaClientKnownRequestError) { 170 | if (e.code === 'P2017') throw new UserInputError('Had been unfavorited'); 171 | } 172 | return origin; 173 | } 174 | }, 175 | }); 176 | }, 177 | }); 178 | 179 | export async function checkArticle(ctx: Context, slug: string) { 180 | const origin = await ctx.prisma.article.findUnique({ where: { slug } }); 181 | if (!origin || origin.del) throw new UserInputError('Article not found'); 182 | return origin; 183 | } 184 | 185 | export async function checkArticleById(ctx: Context, id: number) { 186 | const origin = await ctx.prisma.article.findUnique({ where: { id } }); 187 | if (!origin || origin.del) throw new UserInputError('Article not found'); 188 | return origin; 189 | } 190 | 191 | function checkArticleOwner(ctx: Context, article: Article) { 192 | if (ctx.currentUser!.id !== article.authorId) throw new AuthenticationError('unauthorized'); 193 | } 194 | 195 | export default ArticleMutation; 196 | -------------------------------------------------------------------------------- /components/common/CustomButton.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, DetailedHTMLProps } from 'react'; 2 | import { joinStyles, joinStylesFromArray } from '../../lib/utils/styles-builder'; 3 | 4 | export type ButtonColor = 'primary' | 'secondary' | 'danger'; 5 | export type ButtonSize = 's' | 'm' | 'l'; 6 | type ButtonMode = 'default' | 'outline'; 7 | type ButtonColorPattern = 8 | | 'bgColor' 9 | | 'color' 10 | | 'hover' 11 | | 'focus' 12 | | 'active' 13 | | 'activeHover' 14 | | 'activeFocus' 15 | | 'disabled' 16 | | 'disabledHover' 17 | | 'disabledFocus'; 18 | 19 | export type ButtonProps = Partial<{ 20 | color: ButtonColor; 21 | size: ButtonSize; 22 | outlined?: boolean; 23 | }> & 24 | Omit, HTMLButtonElement>, 'color'>; 25 | 26 | const buttonConfig = { 27 | shape: 'rounded border appearance-none', 28 | focus: 'focus:ring-4 focus:ring-opacity-50', 29 | active: 'active:ring-4 active:ring-opacity-50', 30 | activeFocus: 'active:focus:ring-4 active:focus:ring-opacity-50', 31 | disabled: 'rounded border disabled:cursor-not-allowed disabled:ring-0', 32 | }; 33 | 34 | const buttonColorConfig: { 35 | [key in ButtonColor]: { [key in ButtonMode]: Partial<{ [key in ButtonColorPattern]: string }> }; 36 | } = { 37 | primary: { 38 | default: { 39 | bgColor: 'bg-primary border-primary', 40 | color: 'text-white', 41 | hover: 'hover:bg-primary-600 hover:border-primary-700', 42 | focus: 'focus:bg-primary-600 focus:border-primary-700 focus:ring-primary', 43 | active: 'active:bg-primary-600 active:border-primary-700 active:ring-primary', 44 | activeHover: 'active:hover:bg-primary-800 active:hover:border-primary-900', 45 | activeFocus: 'active:focus:bg-primary-800 active:focus:border-primary-900 active:focus:ring-primary', 46 | disabled: 'disabled:bg-primary-300 disabled:border-primary-300', 47 | disabledHover: 'disabled:hover:bg-primary-300 disabled:hover:border-primary-300', 48 | disabledFocus: 'disabled:focus:bg-primary-300 disabled:focus:border-primary-300', 49 | }, 50 | outline: { 51 | bgColor: 'border-primary bg-transparent', 52 | color: 'text-primary', 53 | hover: 'hover:bg-primary hover:border-primary hover:text-white', 54 | focus: 'focus:bg-primary focus:border-primary focus:text-white focus:ring-primary', 55 | active: 'active:bg-primary active:border-primary active:text-white active:ring-primary', 56 | activeHover: 'active:hover:bg-primary-800 active:hover:border-primary-900 active:hover:text-white', 57 | activeFocus: 58 | 'active:focus:bg-primary-800 active:focus:border-primary-900 active:focus:text-white active:focus:ring-primary', 59 | disabled: 'disabled:bg-primary-300 disabled:border-primary-300 disabled:text-white', 60 | disabledHover: 'disabled:hover:bg-primary-300 disabled:hover:border-primary-300 disabled:hover:text-white', 61 | disabledFocus: 'disabled:focus:bg-primary-300 disabled:focus:border-primary-300 disabled:hover:text-white', 62 | }, 63 | }, 64 | secondary: { 65 | default: { 66 | bgColor: 'bg-gray-400 border-gray-400', 67 | color: 'text-white', 68 | hover: 'hover:bg-gray-500 hover:border-gray-600', 69 | focus: 'focus:bg-gray-500 focus:border-gray-600 focus:ring-gray-400', 70 | active: 'active:bg-gray-500 active:border-gray-600 active:ring-gray-400', 71 | activeHover: 'active:hover:bg-gray-500 active:hover:border-gray-600', 72 | activeFocus: 'active:focus:bg-gray-600 active:focus:border-gray-700 active:focus:ring-gray-400', 73 | disabled: 'disabled:bg-gray-300 disabled:border-gray-300', 74 | disabledHover: 'disabled:hover:bg-gray-300 disabled:hover:border-gray-300', 75 | disabledFocus: 'disabled:focus:bg-gray-300 disabled:focus:border-gray-300', 76 | }, 77 | outline: { 78 | bgColor: 'border-gray-400 bg-transparent', 79 | color: 'text-gray-400', 80 | hover: 'hover:bg-gray-400 hover:border-gray-400 hover:text-white', 81 | focus: 'focus:bg-gray-400 focus:border-gray-500 focus:text-white focus:ring-gray-400', 82 | active: 'active:bg-gray-400 active:border-gray-500 active:text-white active:ring-gray-400', 83 | activeHover: 'active:hover:bg-gray-400 active:hover:border-gray-500 active:hover:text-white', 84 | activeFocus: 85 | 'active:focus:bg-gray-500 active:focus:border-gray-600 active:focus:text-white active:focus:ring-gray-400', 86 | disabled: 'disabled:bg-gray-300 disabled:border-gray-300 disabled:text-white', 87 | disabledHover: 'disabled:hover:bg-gray-300 disabled:hover:border-gray-300 disabled:hover:text-white', 88 | disabledFocus: 'disabled:focus:bg-gray-300 disabled:focus:border-gray-300 disabled:hover:text-white', 89 | }, 90 | }, 91 | danger: { 92 | default: { 93 | bgColor: 'bg-red-600 border-red-600', 94 | color: 'text-white', 95 | hover: 'hover:bg-red-700 hover:border-red-700', 96 | focus: 'focus:bg-red-700 focus:border-red-800 focus:ring-red-300', 97 | active: 'active:bg-red-700 active:border-red-800 active:ring-red-300', 98 | activeHover: 'active:hover:bg-red-700 active:hover:border-red-800', 99 | activeFocus: 'active:focus:bg-red-800 active:focus:border-red-900 active:focus:ring-red-300', 100 | disabled: 'disabled:bg-red-300 disabled:border-red-300', 101 | disabledHover: 'disabled:hover:bg-red-300 disabled:hover:border-red-300', 102 | disabledFocus: 'disabled:focus:bg-red-300 disabled:focus:border-red-300', 103 | }, 104 | outline: { 105 | bgColor: 'border-red-600 bg-transparent', 106 | color: 'text-red-600', 107 | hover: 'hover:bg-red-600 hover:border-red-600 hover:text-white', 108 | focus: 'focus:bg-red-600 focus:border-red-700 focus:text-white focus:ring-red-300', 109 | active: 'active:bg-red-600 active:border-red-700 active:text-white active:ring-red-300', 110 | activeHover: 'active:hover:bg-red-600 active:hover:border-red-700 active:hover:text-white', 111 | activeFocus: 112 | 'active:focus:bg-red-700 active:focus:border-red-800 active:focus:text-white active:focus:ring-red-300', 113 | disabled: 'disabled:bg-red-300 disabled:border-red-300 disabled:text-white', 114 | disabledHover: 'disabled:hover:bg-red-300 disabled:hover:border-red-300 disabled:hover:text-white', 115 | disabledFocus: 'disabled:focus:bg-red-300 disabled:focus:border-red-300 disabled:hover:text-white', 116 | }, 117 | }, 118 | }; 119 | 120 | const buttonSizeConfig: { [key in ButtonSize]: string } = { 121 | s: 'px-2 py-1', 122 | m: 'px-3.5 py-2', 123 | l: 'px-5 py-2 text-xl font-medium', 124 | }; 125 | 126 | export const joinButtonStyles = ({ 127 | color, 128 | size, 129 | outlined, 130 | className, 131 | }: Pick) => 132 | joinStylesFromArray( 133 | joinStyles(buttonConfig), 134 | size && buttonSizeConfig[size], 135 | color && (outlined ? joinStyles(buttonColorConfig[color].outline) : joinStyles(buttonColorConfig[color].default)), 136 | className 137 | ); 138 | 139 | export default function CustomButton({ 140 | type = 'button', 141 | size = 'm', 142 | outlined = false, 143 | color = 'primary', 144 | className, 145 | children, 146 | ...props 147 | }: ButtonProps) { 148 | return ( 149 | 152 | ); 153 | } 154 | --------------------------------------------------------------------------------