Compare commits
	
		
			13 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb8099d03 | |||
| cf24177017 | |||
| 3a6822bcec | |||
|  | c5ca8819e7 | ||
|  | 9490d874a9 | ||
|  | 7d04d53d02 | ||
|  | e21e6fc3e2 | ||
|  | 9334a45e55 | ||
|  | d29eeaae90 | ||
|  | 4a533748f3 | ||
|  | 183564aed8 | ||
|  | 0b5fa5b643 | ||
|  | 2e70c31849 | 
							
								
								
									
										23
									
								
								.cracorc.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								.cracorc.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | const { ServiceRegister } = require("@fennec/configuration"); | ||||||
|  | const { findFreePorts } = require("find-free-ports"); | ||||||
|  |  | ||||||
|  | module.exports = async function () { | ||||||
|  |   return { | ||||||
|  |     devServer: { | ||||||
|  |       open: false, | ||||||
|  |       port: await findFreePorts().then((ports) => ports[0]), | ||||||
|  |       onListening(devServer) { | ||||||
|  |         if (!devServer) { | ||||||
|  |           throw new Error("webpack-dev-server is not defined"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const port = devServer.listeningApp.address().port; | ||||||
|  |         const register = new ServiceRegister({ | ||||||
|  |           etcd: { hosts: ["http://rpi:2379"] }, | ||||||
|  |         }); | ||||||
|  |         register.register("admin.blog", `http://localhost:${port}/`); | ||||||
|  |         console.log("Listening on port:", port); | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
							
								
								
									
										17
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | { | ||||||
|  |   // Use IntelliSense to learn about possible attributes. | ||||||
|  |   // Hover to view descriptions of existing attributes. | ||||||
|  |   // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 | ||||||
|  |   "version": "0.2.0", | ||||||
|  |   "configurations": [ | ||||||
|  |     { | ||||||
|  |       "name": "chrome", | ||||||
|  |       "type": "chrome", | ||||||
|  |       "request": "launch", | ||||||
|  |       "reAttach": true, | ||||||
|  |       "url": "http://admin.blog.localhost:7070/", | ||||||
|  |       "webRoot": "${workspaceFolder}", | ||||||
|  |       "userDataDir": "/Users/ivan/Projects/.chrome" | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  | } | ||||||
							
								
								
									
										6
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,9 @@ | |||||||
| { | { | ||||||
|   "cSpell.words": [ |   "cSpell.words": [ | ||||||
|     "fontsource" |     "Formik", | ||||||
|  |     "clsx", | ||||||
|  |     "fontsource", | ||||||
|  |     "notistack", | ||||||
|  |     "vditor" | ||||||
|   ] |   ] | ||||||
| } | } | ||||||
							
								
								
									
										8
									
								
								apollo.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								apollo.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | module.exports = { | ||||||
|  |   client: { | ||||||
|  |     service: { | ||||||
|  |       name: "blog-be", | ||||||
|  |       url: "http://api.blog.localhost:7070/graphql", | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										12
									
								
								codegen.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								codegen.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | overwrite: true | ||||||
|  | schema: "http://api.blog.localhost:7070/graphql" | ||||||
|  | # documents: "src/**/*.graphql" | ||||||
|  | generates: | ||||||
|  |   src/generated/graphql.tsx: | ||||||
|  |     plugins: | ||||||
|  |       - "typescript" | ||||||
|  |       - "typescript-operations" | ||||||
|  |       - "typescript-react-apollo" | ||||||
|  |   src/generated/graphql.schema.json: | ||||||
|  |     plugins: | ||||||
|  |       - "introspection" | ||||||
							
								
								
									
										19
									
								
								ecosystem.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								ecosystem.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | module.exports = { | ||||||
|  |   apps: [ | ||||||
|  |     { | ||||||
|  |       name: "blog-bs", | ||||||
|  |       script: "serve", | ||||||
|  |       args: "", | ||||||
|  |       watch: false, | ||||||
|  |       ignore_watch: ["node_modules"], | ||||||
|  |       log_date_format: "MM-DD HH:mm:ss.SSS Z", | ||||||
|  |       env: { | ||||||
|  |         PM2_SERVE_PATH: "./build", | ||||||
|  |         PM2_SERVE_PORT: 7135, | ||||||
|  |         PM2_SERVE_SPA: "true", | ||||||
|  |         PM2_SERVE_HOMEPAGE: "/index.html", | ||||||
|  |       }, | ||||||
|  |       max_restarts: 5, | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  | }; | ||||||
							
								
								
									
										50266
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										50266
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										47
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								package.json
									
									
									
									
									
								
							| @@ -4,8 +4,17 @@ | |||||||
|   "private": true, |   "private": true, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@apollo/client": "^3.3.15", |     "@apollo/client": "^3.3.15", | ||||||
|  |     "@craco/craco": "^6.4.0", | ||||||
|  |     "@curi/react-dom": "^2.0.4", | ||||||
|  |     "@curi/router": "^2.1.2", | ||||||
|  |     "@date-io/date-fns": "^1.3.13", | ||||||
|  |     "@fennec/configuration": "^0.0.1", | ||||||
|  |     "@hickory/browser": "^2.1.0", | ||||||
|     "@material-ui/core": "^4.11.3", |     "@material-ui/core": "^4.11.3", | ||||||
|     "@material-ui/icons": "^4.11.2", |     "@material-ui/icons": "^4.11.2", | ||||||
|  |     "@material-ui/lab": "*", | ||||||
|  |     "@material-ui/pickers": "^3.3.10", | ||||||
|  |     "@nestjs-lib/auth": "^0.1.1", | ||||||
|     "@testing-library/jest-dom": "^5.11.10", |     "@testing-library/jest-dom": "^5.11.10", | ||||||
|     "@testing-library/react": "^11.2.6", |     "@testing-library/react": "^11.2.6", | ||||||
|     "@testing-library/user-event": "^12.8.3", |     "@testing-library/user-event": "^12.8.3", | ||||||
| @@ -13,19 +22,36 @@ | |||||||
|     "@types/node": "^12.20.10", |     "@types/node": "^12.20.10", | ||||||
|     "@types/react": "^17.0.3", |     "@types/react": "^17.0.3", | ||||||
|     "@types/react-dom": "^17.0.3", |     "@types/react-dom": "^17.0.3", | ||||||
|  |     "apollo-link-scalars": "^2.1.3", | ||||||
|  |     "configuration": "^2.4.2", | ||||||
|  |     "date-fns": "^2.22.1", | ||||||
|  |     "eventemitter3": "^4.0.7", | ||||||
|  |     "events": "^3.3.0", | ||||||
|  |     "find-free-ports": "^3.0.0", | ||||||
|     "fontsource-roboto": "^4.0.0", |     "fontsource-roboto": "^4.0.0", | ||||||
|  |     "formik": "^2.2.6", | ||||||
|  |     "formik-material-ui": "^3.0.1", | ||||||
|  |     "formik-material-ui-pickers": "^0.0.12", | ||||||
|     "graphql": "^15.5.0", |     "graphql": "^15.5.0", | ||||||
|  |     "graphql-scalars": "^1.10.0", | ||||||
|  |     "notistack": "^1.0.9", | ||||||
|  |     "npm": "^7.20.6", | ||||||
|  |     "ramda": "^0.27.1", | ||||||
|     "react": "^17.0.2", |     "react": "^17.0.2", | ||||||
|     "react-dom": "^17.0.2", |     "react-dom": "^17.0.2", | ||||||
|     "react-scripts": "4.0.3", |     "react-scripts": "4.0.3", | ||||||
|  |     "subscriptions-transport-ws": "^0.9.19", | ||||||
|     "typescript": "^4.2.4", |     "typescript": "^4.2.4", | ||||||
|     "web-vitals": "^1.1.1" |     "vditor": "^3.8.4", | ||||||
|  |     "web-vitals": "^1.1.1", | ||||||
|  |     "yup": "^0.32.9" | ||||||
|   }, |   }, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "start": "react-scripts start", |     "start": "craco start", | ||||||
|     "build": "react-scripts build", |     "build": "craco build", | ||||||
|     "test": "react-scripts test", |     "test": "craco test", | ||||||
|     "eject": "react-scripts eject" |     "prestart": "npm run graphql", | ||||||
|  |     "graphql": "graphql-codegen --config codegen.yml" | ||||||
|   }, |   }, | ||||||
|   "eslintConfig": { |   "eslintConfig": { | ||||||
|     "extends": [ |     "extends": [ | ||||||
| @@ -46,6 +72,15 @@ | |||||||
|     ] |     ] | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@types/graphql": "^14.5.0" |     "@graphql-codegen/cli": "1.21.3", | ||||||
|  |     "@graphql-codegen/introspection": "1.18.1", | ||||||
|  |     "@graphql-codegen/typescript": "1.21.1", | ||||||
|  |     "@graphql-codegen/typescript-operations": "1.17.15", | ||||||
|  |     "@graphql-codegen/typescript-react-apollo": "2.2.3", | ||||||
|  |     "@types/graphql": "^14.5.0", | ||||||
|  |     "@types/ramda": "^0.27.40", | ||||||
|  |     "@types/sass": "^1.16.0", | ||||||
|  |     "@types/yup": "^0.29.11", | ||||||
|  |     "sass": "^1.32.11" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,9 +0,0 @@ | |||||||
| import React from 'react'; |  | ||||||
| import { render, screen } from '@testing-library/react'; |  | ||||||
| import App from './App'; |  | ||||||
|  |  | ||||||
| test('renders learn react link', () => { |  | ||||||
|   render(<App />); |  | ||||||
|   const linkElement = screen.getByText(/learn react/i); |  | ||||||
|   expect(linkElement).toBeInTheDocument(); |  | ||||||
| }); |  | ||||||
							
								
								
									
										14
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -1,13 +1,15 @@ | |||||||
| import React from 'react'; | import { useResponse } from '@curi/react-dom'; | ||||||
| import logo from './logo.svg'; |  | ||||||
| import './App.css'; | import './App.css'; | ||||||
| import { Button } from '@material-ui/core'; | import { DefaultLayout } from './layouts'; | ||||||
|  |  | ||||||
| function App() { | function App() { | ||||||
|  | const { response } = useResponse(); | ||||||
|  |  | ||||||
|  | const { body: Body } = response; | ||||||
|   return ( |   return ( | ||||||
|     <Button variant="contained" color="primary"> |     <DefaultLayout> | ||||||
|       你好,世界 |       <Body response={response} /> | ||||||
|     </Button> |     </DefaultLayout> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										280
									
								
								src/articles/article-editor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								src/articles/article-editor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,280 @@ | |||||||
|  | import { | ||||||
|  |   Button, | ||||||
|  |   createStyles, | ||||||
|  |   Grid, | ||||||
|  |   makeStyles, | ||||||
|  |   Paper, | ||||||
|  | } from "@material-ui/core"; | ||||||
|  | import { Field, Form, Formik, FormikHelpers, FormikProps } from "formik"; | ||||||
|  | import { FC, useCallback, useMemo, useRef, useState } from "react"; | ||||||
|  | import { Editor } from "../commons/editor/vditor"; | ||||||
|  | import * as Yup from "yup"; | ||||||
|  | import { | ||||||
|  |   Article, | ||||||
|  |   CreateArticleInput, | ||||||
|  |   CreateDraftArticleInput, | ||||||
|  |   DraftArticle, | ||||||
|  |   UpdateArticleInput, | ||||||
|  | } from "../generated/graphql"; | ||||||
|  | import { DateTimePicker } from "formik-material-ui-pickers"; | ||||||
|  | import { gql, useMutation } from "@apollo/client"; | ||||||
|  | import { useRouter } from "@curi/react-universal"; | ||||||
|  | import * as R from "ramda"; | ||||||
|  |  | ||||||
|  | const CREATE_ARTICLE = gql` | ||||||
|  |   mutation createArticle($createArticleInput: CreateArticleInput!) { | ||||||
|  |     createArticle(createArticleInput: $createArticleInput) { | ||||||
|  |       id | ||||||
|  |       title | ||||||
|  |       content | ||||||
|  |       publishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const UPDATE_ARTICLE = gql` | ||||||
|  |   mutation updateArticle($updateArticleInput: UpdateArticleInput!) { | ||||||
|  |     updateArticle(updateArticleInput: $updateArticleInput) { | ||||||
|  |       id | ||||||
|  |       title | ||||||
|  |       content | ||||||
|  |       publishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const useStyles = makeStyles((theme) => | ||||||
|  |   createStyles({ | ||||||
|  |     form: { | ||||||
|  |       padding: "15px 30px", | ||||||
|  |     }, | ||||||
|  |     editor: { | ||||||
|  |       height: "700px", | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const fields = ["title", "content", "publishedAt", "tags"] as const; | ||||||
|  | type Values = Pick<Article, typeof fields[number]> & | ||||||
|  |   Partial<Pick<Article, "id" | "key">>; | ||||||
|  | interface Props { | ||||||
|  |   article?: Values; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const ArticleEditor: FC<Props> = ({ article }) => { | ||||||
|  |   const classes = useStyles(); | ||||||
|  |   const router = useRouter(); | ||||||
|  |   const formik = useRef<FormikProps<Values>>(null); | ||||||
|  |  | ||||||
|  |   const validationSchema = Yup.object({ | ||||||
|  |     content: Yup.string() | ||||||
|  |       .max(65535, "文章内容不得超过 65535 个字符") | ||||||
|  |       .required("文章内容不得为空"), | ||||||
|  |     publishedAt: Yup.date(), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const [createArticle] = useMutation< | ||||||
|  |     { createArticle: Article }, | ||||||
|  |     { | ||||||
|  |       createArticleInput: CreateArticleInput; | ||||||
|  |     } | ||||||
|  |   >(CREATE_ARTICLE, { | ||||||
|  |     update(cache, { data }) { | ||||||
|  |       cache.modify({ | ||||||
|  |         fields: { | ||||||
|  |           articles(existingArticles = []) { | ||||||
|  |             const newArticleRef = cache.writeFragment({ | ||||||
|  |               data: data!.createArticle, | ||||||
|  |               fragment: gql` | ||||||
|  |                 fragment NewArticle on Article { | ||||||
|  |                   id | ||||||
|  |                   title | ||||||
|  |                   content | ||||||
|  |                   publishedAt | ||||||
|  |                 } | ||||||
|  |               `, | ||||||
|  |             }); | ||||||
|  |             return [newArticleRef, ...existingArticles]; | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |   const [updateArticle] = useMutation<{ | ||||||
|  |     updateArticleInput: UpdateArticleInput; | ||||||
|  |   }>(UPDATE_ARTICLE); | ||||||
|  |  | ||||||
|  |   const [putDraft] = useMutation< | ||||||
|  |     { | ||||||
|  |       draft: DraftArticle; | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       draft: CreateDraftArticleInput; | ||||||
|  |     } | ||||||
|  |   >(gql` | ||||||
|  |     mutation ($draft: CreateDraftArticleInput!) { | ||||||
|  |       draft: putDraftForArticle(draft: $draft) { | ||||||
|  |         id | ||||||
|  |         createdAt | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   `); | ||||||
|  |  | ||||||
|  |   const tempKey = useMemo(() => { | ||||||
|  |     if (article?.key) { | ||||||
|  |       return article.key; | ||||||
|  |     } else { | ||||||
|  |       return new Date().valueOf().toString(); | ||||||
|  |     } | ||||||
|  |   }, [article]); | ||||||
|  |  | ||||||
|  |   const submitForm = async ( | ||||||
|  |     values: Values, | ||||||
|  |     { setSubmitting }: FormikHelpers<Values> | ||||||
|  |   ) => { | ||||||
|  |     if ("id" in article!) { | ||||||
|  |       const title = /# (.+)$/m.exec(values.content ?? "")?.[1]; | ||||||
|  |       if (!title) { | ||||||
|  |         console.log("no title"); | ||||||
|  |         setSubmitting(false); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       await updateArticle({ | ||||||
|  |         variables: { | ||||||
|  |           updateArticleInput: { | ||||||
|  |             ...values, | ||||||
|  |             title, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |         .then(() => { | ||||||
|  |           router.navigate({ | ||||||
|  |             url: router.url({ name: "articles" }), | ||||||
|  |             method: "replace", | ||||||
|  |           }); | ||||||
|  |         }) | ||||||
|  |         .finally(() => { | ||||||
|  |           setSubmitting(false); | ||||||
|  |         }); | ||||||
|  |     } else { | ||||||
|  |       const title = /# (.+)$/m.exec(values.content ?? "")?.[1]; | ||||||
|  |       if (!title) { | ||||||
|  |         console.log("no title"); | ||||||
|  |         setSubmitting(false); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       await createArticle({ | ||||||
|  |         variables: { | ||||||
|  |           createArticleInput: { | ||||||
|  |             ...(values as CreateArticleInput), | ||||||
|  |             title, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |         .then(() => { | ||||||
|  |           router.navigate({ | ||||||
|  |             url: router.url({ name: "articles" }), | ||||||
|  |             method: "replace", | ||||||
|  |           }); | ||||||
|  |         }) | ||||||
|  |         .finally(() => { | ||||||
|  |           setSubmitting(false); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |     setSubmitting(false); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const [drafts, setDrafts] = useState<DraftArticle[]>([]); | ||||||
|  |   const handleSave = useCallback( | ||||||
|  |     async (automatically = true) => { | ||||||
|  |       const { data } = await putDraft({ | ||||||
|  |         variables: { | ||||||
|  |           draft: { | ||||||
|  |             key: tempKey, | ||||||
|  |             payload: formik.current?.values, | ||||||
|  |             automatically, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |       if (!data?.draft) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       setDrafts((drafts) => { | ||||||
|  |         const index = drafts.findIndex((it) => it.id === data.draft.id); | ||||||
|  |         if (index === -1) { | ||||||
|  |           return [data.draft, ...drafts]; | ||||||
|  |         } else { | ||||||
|  |           const arr = [...drafts]; | ||||||
|  |           arr[index] = data.draft; | ||||||
|  |           return arr; | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |     [tempKey, putDraft, formik, drafts] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Paper> | ||||||
|  |       <Formik | ||||||
|  |         initialValues={{ | ||||||
|  |           ...R.pick(fields, article!), | ||||||
|  |         }} | ||||||
|  |         innerRef={formik} | ||||||
|  |         validationSchema={validationSchema} | ||||||
|  |         autoComplete="off" | ||||||
|  |         onSubmit={submitForm} | ||||||
|  |       > | ||||||
|  |         {({ submitForm, isSubmitting }) => ( | ||||||
|  |           <Form className={classes.form}> | ||||||
|  |             <Grid container spacing={3}> | ||||||
|  |               <Grid item xs={12}> | ||||||
|  |                 <Editor | ||||||
|  |                   className={classes.editor} | ||||||
|  |                   name="content" | ||||||
|  |                   label="Content" | ||||||
|  |                 /> | ||||||
|  |               </Grid> | ||||||
|  |               <Grid item xs={12}> | ||||||
|  |                 <Field | ||||||
|  |                   ampm={false} | ||||||
|  |                   component={DateTimePicker} | ||||||
|  |                   label="Published At" | ||||||
|  |                   name="publishedAt" | ||||||
|  |                   format="yyyy-MM-dd HH:mm:ss" | ||||||
|  |                 /> | ||||||
|  |               </Grid> | ||||||
|  |               <Grid item xs={12}> | ||||||
|  |                 <Button | ||||||
|  |                   variant="contained" | ||||||
|  |                   color="primary" | ||||||
|  |                   disabled={isSubmitting} | ||||||
|  |                   onClick={() => handleSave(false)} | ||||||
|  |                 > | ||||||
|  |                   Save | ||||||
|  |                 </Button> | ||||||
|  |                 <Button | ||||||
|  |                   style={{ marginLeft: "10px" }} | ||||||
|  |                   variant="contained" | ||||||
|  |                   color="secondary" | ||||||
|  |                   disabled={isSubmitting} | ||||||
|  |                   onClick={submitForm} | ||||||
|  |                 > | ||||||
|  |                   Publish | ||||||
|  |                 </Button> | ||||||
|  |               </Grid> | ||||||
|  |             </Grid> | ||||||
|  |           </Form> | ||||||
|  |         )} | ||||||
|  |       </Formik> | ||||||
|  |     </Paper> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | ArticleEditor.defaultProps = { | ||||||
|  |   article: { | ||||||
|  |     title: "", | ||||||
|  |     content: "", | ||||||
|  |     publishedAt: new Date(), | ||||||
|  |     tags: [], | ||||||
|  |   } as Values, | ||||||
|  | }; | ||||||
							
								
								
									
										89
									
								
								src/articles/articles-index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/articles/articles-index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | import { useMutation, useQuery } from "@apollo/client"; | ||||||
|  | import { | ||||||
|  |   IconButton, | ||||||
|  |   Paper, | ||||||
|  |   Table, | ||||||
|  |   TableBody, | ||||||
|  |   TableCell, | ||||||
|  |   TableContainer, | ||||||
|  |   TableHead, | ||||||
|  |   TableRow, | ||||||
|  | } from "@material-ui/core"; | ||||||
|  | import React, { FC } from "react"; | ||||||
|  | import { Article } from "../generated/graphql"; | ||||||
|  | import EditIcon from "@material-ui/icons/Edit"; | ||||||
|  | import { useRouter } from "@curi/react-dom"; | ||||||
|  | import { ARTICLES, REMOVE_ARTICLE } from './articles.constants'; | ||||||
|  | import { Delete } from '@material-ui/icons'; | ||||||
|  | import { format } from "date-fns"; | ||||||
|  |  | ||||||
|  | export const ArticleIndex: FC = () => { | ||||||
|  |   const { data } = useQuery<{ | ||||||
|  |     articles: Article[]; | ||||||
|  |   }>(ARTICLES, {}); | ||||||
|  |  | ||||||
|  |   const [removeArticle] = useMutation<any, { id: string }>(REMOVE_ARTICLE); | ||||||
|  |  | ||||||
|  |   const router = useRouter(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <section> | ||||||
|  |       <TableContainer component={Paper}> | ||||||
|  |         <Table aria-label="articles table"> | ||||||
|  |           <TableHead> | ||||||
|  |             <TableRow> | ||||||
|  |               <TableCell>Article Title</TableCell> | ||||||
|  |               <TableCell>Published At</TableCell> | ||||||
|  |               <TableCell>Views</TableCell> | ||||||
|  |               <TableCell>Comments</TableCell> | ||||||
|  |               <TableCell>Status</TableCell> | ||||||
|  |               <TableCell>Actions</TableCell> | ||||||
|  |             </TableRow> | ||||||
|  |           </TableHead> | ||||||
|  |           <TableBody> | ||||||
|  |             {data?.articles.map((article) => ( | ||||||
|  |               <TableRow key={article.id}> | ||||||
|  |                 <TableCell component="th" scope="row"> | ||||||
|  |                   {article.title} | ||||||
|  |                 </TableCell> | ||||||
|  |                 <TableCell> | ||||||
|  |                   {article.publishedAt | ||||||
|  |                     ? format(article.publishedAt, "yyyy-MM-dd HH:mm:ss") | ||||||
|  |                     : "--"} | ||||||
|  |                 </TableCell> | ||||||
|  |                 <TableCell align="right"> -- </TableCell> | ||||||
|  |                 <TableCell align="right"> -- </TableCell> | ||||||
|  |                 <TableCell align="center">{"Published"}</TableCell> | ||||||
|  |                 <TableCell> | ||||||
|  |                   <IconButton | ||||||
|  |                     aria-label="edit" | ||||||
|  |                     onClick={() => | ||||||
|  |                       router.navigate({ | ||||||
|  |                         url: router.url({ | ||||||
|  |                           name: "modify-article", | ||||||
|  |                           params: article, | ||||||
|  |                         }), | ||||||
|  |                       }) | ||||||
|  |                     } | ||||||
|  |                   > | ||||||
|  |                     <EditIcon /> | ||||||
|  |                   </IconButton> | ||||||
|  |                   <IconButton | ||||||
|  |                     aria-label="delete" | ||||||
|  |                     onClick={() => | ||||||
|  |                       removeArticle({ | ||||||
|  |                         variables: article, | ||||||
|  |                       }) | ||||||
|  |                     } | ||||||
|  |                   > | ||||||
|  |                     <Delete /> | ||||||
|  |                   </IconButton> | ||||||
|  |                 </TableCell> | ||||||
|  |               </TableRow> | ||||||
|  |             ))} | ||||||
|  |           </TableBody> | ||||||
|  |         </Table> | ||||||
|  |       </TableContainer> | ||||||
|  |     </section> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										26
									
								
								src/articles/articles.constants.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/articles/articles.constants.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | import { gql } from "@apollo/client"; | ||||||
|  |  | ||||||
|  | export const ARTICLE = gql` | ||||||
|  |   query Article($id: String!) { | ||||||
|  |     article(id: $id) { | ||||||
|  |       id | ||||||
|  |       title | ||||||
|  |       content | ||||||
|  |       publishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  | export const ARTICLES = gql` | ||||||
|  |   query Articles { | ||||||
|  |     articles { | ||||||
|  |       id | ||||||
|  |       title | ||||||
|  |       publishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  | export const REMOVE_ARTICLE = gql` | ||||||
|  |   mutation RemoveArticle($id: String!) { | ||||||
|  |     removeArticle(id: $id) | ||||||
|  |   } | ||||||
|  | `; | ||||||
							
								
								
									
										3
									
								
								src/articles/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/articles/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export * from './article-editor'; | ||||||
|  | export * from './articles-index'; | ||||||
|  | export * from './articles.constants' | ||||||
							
								
								
									
										55
									
								
								src/commons/auth/auth.provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/commons/auth/auth.provider.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | import { createContext, useContext, useState } from "react"; | ||||||
|  | import { FC } from "react"; | ||||||
|  | import { Login } from "./login"; | ||||||
|  |  | ||||||
|  | export interface AuthContext { | ||||||
|  |   accessToken: string | null; | ||||||
|  |   setAccessToken: (token: string) => void; | ||||||
|  |   setRefreshToken: (token: string) => void; | ||||||
|  |   refreshToken: string | undefined; | ||||||
|  |   login: (dto: any) => void; | ||||||
|  |   account?: any; | ||||||
|  |   setAccount: (dto: any) => void; | ||||||
|  |   logout: () => void; | ||||||
|  | } | ||||||
|  | const Context = createContext({} as AuthContext); | ||||||
|  |  | ||||||
|  | export const useAuth = () => useContext(Context); | ||||||
|  |  | ||||||
|  | export const AuthProvider: FC = ({ children }) => { | ||||||
|  |   const [accessToken, setAccessToken] = useState<string | null>( | ||||||
|  |     localStorage.getItem("accessToken") | ||||||
|  |   ); | ||||||
|  |   const [refreshToken, setRefreshToken] = useState<string>(); | ||||||
|  |   const [account, setAccount] = useState<any>(); | ||||||
|  |  | ||||||
|  |   const login = (dto: any) => { | ||||||
|  |     setAccessToken(dto.accessToken); | ||||||
|  |     setRefreshToken(dto.refreshToken); | ||||||
|  |     setAccount(dto.account); | ||||||
|  |     localStorage.setItem("accessToken", dto.accessToken); | ||||||
|  |   }; | ||||||
|  |   const logout = () => { | ||||||
|  |     setAccessToken(null); | ||||||
|  |     setRefreshToken(undefined); | ||||||
|  |     setAccount(undefined); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Context.Provider | ||||||
|  |       value={{ | ||||||
|  |         accessToken, | ||||||
|  |         setAccessToken, | ||||||
|  |         refreshToken, | ||||||
|  |         setRefreshToken, | ||||||
|  |         login, | ||||||
|  |         account, | ||||||
|  |         setAccount, | ||||||
|  |         logout, | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       {children} | ||||||
|  |       {accessToken ? null : <Login />} | ||||||
|  |     </Context.Provider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										66
									
								
								src/commons/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/commons/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | |||||||
|  | import { makeStyles } from "@material-ui/core"; | ||||||
|  | import { FC, Fragment, useEffect, useRef } from "react"; | ||||||
|  | import { useAuth } from "./auth.provider"; | ||||||
|  | const useStyles = makeStyles((theme) => ({ | ||||||
|  |   iframe: { | ||||||
|  |     height: "300px", | ||||||
|  |     width: "500px", | ||||||
|  |     position: "absolute", | ||||||
|  |     top: "100px", | ||||||
|  |     left: "50%", | ||||||
|  |     transform: "translateX(-50%)", | ||||||
|  |     zIndex: theme.zIndex.modal, | ||||||
|  |     border: "none", | ||||||
|  |     boxShadow: theme.shadows[4], | ||||||
|  |   }, | ||||||
|  |   mask: { | ||||||
|  |     top: "0", | ||||||
|  |     left: "0", | ||||||
|  |     bottom: "0", | ||||||
|  |     right: "0", | ||||||
|  |     position: "absolute", | ||||||
|  |     backgroundColor: "rgba(0, 0, 0, 0.3)", | ||||||
|  |     zIndex: theme.zIndex.modal, | ||||||
|  |   }, | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | export const Login: FC = () => { | ||||||
|  |   const iframeRef = useRef<HTMLIFrameElement>(null); | ||||||
|  |   const { login } = useAuth(); | ||||||
|  |   useEffect(() => { | ||||||
|  |     const iframe = iframeRef.current; | ||||||
|  |     if (!iframe) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     let messagePort: MessagePort; | ||||||
|  |     const onLoad = (ev: MessageEvent) => { | ||||||
|  |       if (ev.data !== "init-channel") { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       messagePort = ev.ports?.[0] as MessagePort; | ||||||
|  |       messagePort.onmessage = (ev: MessageEvent) => { | ||||||
|  |         if (ev.data?.type === "logged") { | ||||||
|  |           login(ev.data.payload); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |     }; | ||||||
|  |     window.addEventListener("message", onLoad); | ||||||
|  |  | ||||||
|  |     return () => { | ||||||
|  |       window.removeEventListener("message", onLoad); | ||||||
|  |     }; | ||||||
|  |   }, [login]); | ||||||
|  |  | ||||||
|  |   const classes = useStyles(); | ||||||
|  |   return ( | ||||||
|  |     <Fragment> | ||||||
|  |       <div className={classes.mask} /> | ||||||
|  |       <iframe | ||||||
|  |         ref={iframeRef} | ||||||
|  |         className={classes.iframe} | ||||||
|  |         title="Auth" | ||||||
|  |         src="https://user.rpi.ivanli.cc/auth/login" | ||||||
|  |       ></iframe> | ||||||
|  |     </Fragment> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										75
									
								
								src/commons/editor/vditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/commons/editor/vditor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | import { createStyles, FormControl, FormHelperText, FormLabel, makeStyles } from '@material-ui/core'; | ||||||
|  | import { Skeleton } from "@material-ui/lab"; | ||||||
|  | import { FieldHookConfig, useField } from "formik"; | ||||||
|  | import React, { FC, useEffect, useMemo, useRef, useState } from "react"; | ||||||
|  | import Vditor from "vditor"; | ||||||
|  | import "vditor/src/assets/scss/index.scss"; | ||||||
|  |  | ||||||
|  | type Props = FieldHookConfig<string> & { | ||||||
|  |   label?: string; | ||||||
|  |   className?: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const useStyles = makeStyles((theme) => | ||||||
|  |   createStyles({ | ||||||
|  |     formControl: { | ||||||
|  |       width: "100%", | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const Editor: FC<Props> = ({ label, className, ...props }) => { | ||||||
|  |   const [, meta, helpers] = useField(props); | ||||||
|  |   const editor = useRef<HTMLDivElement>(null); | ||||||
|  |   const [instance, setInstance] = useState<Vditor | null>(() => null); | ||||||
|  |   const containerKey = useMemo( | ||||||
|  |     () => Math.random().toString(36).slice(2, 8), | ||||||
|  |     [] | ||||||
|  |   ); | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!editor.current) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const _instance = new Vditor(editor.current, { | ||||||
|  |       cache: { | ||||||
|  |         id: containerKey, | ||||||
|  |       }, | ||||||
|  |       fullscreen: { | ||||||
|  |         index: 1500, | ||||||
|  |       }, | ||||||
|  |       value: meta.initialValue, | ||||||
|  |       input: (val) => helpers.setValue(val), | ||||||
|  |       blur: () => helpers.setTouched(true), | ||||||
|  |       after: () => { | ||||||
|  |         setInstance(_instance); | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return () => { | ||||||
|  |       _instance?.destroy(); | ||||||
|  |     }; | ||||||
|  |     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||
|  |   }, [containerKey]); | ||||||
|  |  | ||||||
|  |   const classes = useStyles(); | ||||||
|  |   return ( | ||||||
|  |     <FormControl className={classes.formControl}> | ||||||
|  |       <FormLabel>{label}</FormLabel> | ||||||
|  |       <div | ||||||
|  |         className={`${className} vditor`} | ||||||
|  |         ref={editor} | ||||||
|  |         key={containerKey} | ||||||
|  |       ></div> | ||||||
|  |       {meta.error && <FormHelperText error={true}>{meta.error}</FormHelperText>} | ||||||
|  |       {instance ? null : ( | ||||||
|  |         <Skeleton | ||||||
|  |           variant="rect" | ||||||
|  |           animation="wave" | ||||||
|  |           height="100%" | ||||||
|  |           width="100%" | ||||||
|  |           style={{ position: "absolute" }} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |     </FormControl> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										178
									
								
								src/commons/graphql/client.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								src/commons/graphql/client.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | |||||||
|  | import { | ||||||
|  |   ApolloClient, | ||||||
|  |   ApolloLink, | ||||||
|  |   HttpLink, | ||||||
|  |   InMemoryCache, | ||||||
|  |   split, | ||||||
|  |   ApolloProvider, | ||||||
|  |   fromPromise, | ||||||
|  |   FetchResult, | ||||||
|  | } from "@apollo/client"; | ||||||
|  | import { withScalars } from "apollo-link-scalars"; | ||||||
|  | import { buildClientSchema, IntrospectionQuery } from "graphql"; | ||||||
|  | import { DateTimeResolver } from "graphql-scalars"; | ||||||
|  | import { FC } from "react"; | ||||||
|  | import introspectionResult from "../../generated/graphql.schema.json"; | ||||||
|  | import { onError } from "@apollo/client/link/error"; | ||||||
|  | import { WebSocketLink } from "@apollo/client/link/ws"; | ||||||
|  | import { getMainDefinition, Observable } from "@apollo/client/utilities"; | ||||||
|  | import { useSnackbar } from "notistack"; | ||||||
|  | import { deepOmit } from "../../utils/deep-omit"; | ||||||
|  | import { useMemo } from "react"; | ||||||
|  | import { EventEmitter } from "events"; | ||||||
|  | import { useState } from "react"; | ||||||
|  | import { useAuth } from "../auth/auth.provider"; | ||||||
|  | import { useEffect } from "react"; | ||||||
|  | import { useRef } from "react"; | ||||||
|  | import { setContext } from "@apollo/client/link/context"; | ||||||
|  |  | ||||||
|  | const schema = buildClientSchema( | ||||||
|  |   introspectionResult as unknown as IntrospectionQuery | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const typesMap = { | ||||||
|  |   DateTime: DateTimeResolver, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const cleanTypeName = new ApolloLink((operation, forward) => { | ||||||
|  |   if (operation.variables) { | ||||||
|  |     operation.variables = deepOmit(["__typename"], operation.variables); | ||||||
|  |   } | ||||||
|  |   const rt = forward(operation); | ||||||
|  |   return ( | ||||||
|  |     rt.map?.((data) => { | ||||||
|  |       return data; | ||||||
|  |     }) ?? rt | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export const AppApolloClientProvider: FC = ({ children }) => { | ||||||
|  |   const { enqueueSnackbar } = useSnackbar(); | ||||||
|  |   const { accessToken, logout } = useAuth(); | ||||||
|  |  | ||||||
|  |   const [loggedEventTarget] = useState(() => new EventEmitter()); | ||||||
|  |   const accessTokenRef = useRef(accessToken); | ||||||
|  |   const logoutRef = useRef(logout); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     accessTokenRef.current = accessToken; | ||||||
|  |     if (accessToken) { | ||||||
|  |       loggedEventTarget.emit("logged", accessToken); | ||||||
|  |     } | ||||||
|  |   }, [loggedEventTarget, accessToken]); | ||||||
|  |   useEffect(() => { | ||||||
|  |     logoutRef.current = logout; | ||||||
|  |   }, [logout]); | ||||||
|  |   const client = useMemo(() => { | ||||||
|  |     const authLink = onError( | ||||||
|  |       ({ graphQLErrors, networkError, operation, forward }) => { | ||||||
|  |         if (graphQLErrors) { | ||||||
|  |           for (const error of graphQLErrors) { | ||||||
|  |             if (error.extensions?.code === "401") { | ||||||
|  |               return fromPromise( | ||||||
|  |                 new Promise<Observable<FetchResult>>((resolve) => { | ||||||
|  |                   loggedEventTarget.once("logged", (accessToken: string) => { | ||||||
|  |                     const oldHeaders = operation.getContext().headers; | ||||||
|  |                     operation.setContext({ | ||||||
|  |                       headers: { | ||||||
|  |                         ...oldHeaders, | ||||||
|  |                         authorization: `Bearer ${accessToken}`, | ||||||
|  |                       }, | ||||||
|  |                     }); | ||||||
|  |                     resolve(forward(operation)); | ||||||
|  |                   }); | ||||||
|  |                   logoutRef.current(); | ||||||
|  |                 }) | ||||||
|  |               ).flatMap((v) => v); | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         const httpResult = (networkError as any)?.result; | ||||||
|  |         if (httpResult?.statusCode === 401) { | ||||||
|  |           return fromPromise( | ||||||
|  |             new Promise<Observable<FetchResult>>((resolve) => { | ||||||
|  |               loggedEventTarget.once("logged", (accessToken: string) => { | ||||||
|  |                 const oldHeaders = operation.getContext().headers; | ||||||
|  |                 operation.setContext({ | ||||||
|  |                   headers: { | ||||||
|  |                     ...oldHeaders, | ||||||
|  |                     authorization: `Bearer ${accessToken}`, | ||||||
|  |                   }, | ||||||
|  |                 }); | ||||||
|  |                 resolve(forward(operation)); | ||||||
|  |               }); | ||||||
|  |               logoutRef.current(); | ||||||
|  |             }) | ||||||
|  |           ).flatMap((v) => v); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     ).concat( | ||||||
|  |       setContext(() => ({ | ||||||
|  |         headers: { | ||||||
|  |           authorization: `Bearer ${accessTokenRef.current}`, | ||||||
|  |         }, | ||||||
|  |       })) | ||||||
|  |     ); | ||||||
|  |     const errorLink = onError(({ graphQLErrors, networkError }) => { | ||||||
|  |       if (graphQLErrors) { | ||||||
|  |         graphQLErrors.forEach((error) => { | ||||||
|  |           enqueueSnackbar(error.message, { | ||||||
|  |             variant: "error", | ||||||
|  |           }); | ||||||
|  |         }); | ||||||
|  |         graphQLErrors.forEach(({ message, locations, path }) => { | ||||||
|  |           console.error( | ||||||
|  |             `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` | ||||||
|  |           ); | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |       if (networkError) { | ||||||
|  |         console.log(`[Network error]: ${networkError}`); | ||||||
|  |         enqueueSnackbar(networkError.message, { | ||||||
|  |           variant: "error", | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     const wsLink = new WebSocketLink({ | ||||||
|  |       uri: `${window.location.protocol.replace("http", "ws")}//${ | ||||||
|  |         window.location.hostname | ||||||
|  |       }:${window.location.port}/api/graphql`, | ||||||
|  |       options: { | ||||||
|  |         reconnect: true, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |     const httpLink = new HttpLink({ | ||||||
|  |       uri: "/api/graphql", | ||||||
|  |     }); | ||||||
|  |     const splitLink = split( | ||||||
|  |       ({ query }) => { | ||||||
|  |         const definition = getMainDefinition(query); | ||||||
|  |         return ( | ||||||
|  |           definition.kind === "OperationDefinition" && | ||||||
|  |           definition.operation === "subscription" | ||||||
|  |         ); | ||||||
|  |       }, | ||||||
|  |       wsLink, | ||||||
|  |       httpLink | ||||||
|  |     ); | ||||||
|  |     const link = ApolloLink.from([ | ||||||
|  |       errorLink, | ||||||
|  |       authLink, | ||||||
|  |       withScalars({ schema, typesMap }) as unknown as ApolloLink, | ||||||
|  |       cleanTypeName, | ||||||
|  |       splitLink, | ||||||
|  |     ]); | ||||||
|  |     const client = new ApolloClient({ | ||||||
|  |       connectToDevTools: true, | ||||||
|  |       ssrMode: typeof window === "undefined", | ||||||
|  |       link, | ||||||
|  |       cache: new InMemoryCache({ | ||||||
|  |         typePolicies: {}, | ||||||
|  |       }), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return client; | ||||||
|  |   }, [enqueueSnackbar, loggedEventTarget]); | ||||||
|  |  | ||||||
|  |   return <ApolloProvider client={client}>{children}</ApolloProvider>; | ||||||
|  | }; | ||||||
							
								
								
									
										20
									
								
								src/commons/route/active-link.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/commons/route/active-link.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | import { ActiveHookProps, Link, LinkProps, useActive } from '@curi/react-dom'; | ||||||
|  | import React, { FC, ReactNode } from 'react'; | ||||||
|  |  | ||||||
|  | export type ActiveLinkProps = ActiveHookProps & | ||||||
|  |   LinkProps & { | ||||||
|  |     className?: string; | ||||||
|  |     children: ReactNode; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  | export const ActiveLink:FC<ActiveLinkProps> = ({ name, params, partial, className = "", ...rest }) => { | ||||||
|  |   const active = useActive({ name, params, partial }); | ||||||
|  |   return ( | ||||||
|  |     <Link | ||||||
|  |       name={name} | ||||||
|  |       params={params} | ||||||
|  |       {...rest} | ||||||
|  |       className={active ? `${className} active` : className} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										31
									
								
								src/commons/route/router.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/commons/route/router.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | import { useApolloClient } from "@apollo/client"; | ||||||
|  | import { createRouterComponent } from "@curi/react-dom"; | ||||||
|  | import { createRouter, announce } from "@curi/router"; | ||||||
|  | import { browser } from "@hickory/browser"; | ||||||
|  | import { FC, useEffect, useState } from "react"; | ||||||
|  | import routes from "../../routes"; | ||||||
|  | import { LinearProgress } from "@material-ui/core"; | ||||||
|  |  | ||||||
|  | const Component: FC = ({ children }) => { | ||||||
|  |   const client = useApolloClient(); | ||||||
|  |   const [body, setBody] = useState<any>(null); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const router = createRouter(browser, routes, { | ||||||
|  |       sideEffects: [ | ||||||
|  |         announce(({ response }) => { | ||||||
|  |           return `Navigated to ${response.location.pathname}`; | ||||||
|  |         }), | ||||||
|  |       ], | ||||||
|  |       external: { client }, | ||||||
|  |     }); | ||||||
|  |     const Router = createRouterComponent(router); | ||||||
|  |     router.once(() => { | ||||||
|  |       setBody(<Router>{children}</Router>); | ||||||
|  |     }); | ||||||
|  |   }, [setBody, client, children]); | ||||||
|  |  | ||||||
|  |   return body ?? <LinearProgress />; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Component; | ||||||
							
								
								
									
										2109
									
								
								src/generated/graphql.schema.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2109
									
								
								src/generated/graphql.schema.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										157
									
								
								src/generated/graphql.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/generated/graphql.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | |||||||
|  | import { gql } from '@apollo/client'; | ||||||
|  | export type Maybe<T> = T | null; | ||||||
|  | export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; | ||||||
|  | export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }; | ||||||
|  | export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }; | ||||||
|  | /** All built-in and custom scalars, mapped to their actual values */ | ||||||
|  | export type Scalars = { | ||||||
|  |   ID: string; | ||||||
|  |   String: string; | ||||||
|  |   Boolean: boolean; | ||||||
|  |   Int: number; | ||||||
|  |   Float: number; | ||||||
|  |   /** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */ | ||||||
|  |   DateTime: any; | ||||||
|  |   /** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ | ||||||
|  |   Object: any; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type Article = { | ||||||
|  |   __typename?: 'Article'; | ||||||
|  |   id: Scalars['ID']; | ||||||
|  |   title: Scalars['String']; | ||||||
|  |   content: Scalars['String']; | ||||||
|  |   publishedAt?: Maybe<Scalars['DateTime']>; | ||||||
|  |   tags: Array<Scalars['String']>; | ||||||
|  |   key?: Maybe<Scalars['String']>; | ||||||
|  |   html: Scalars['String']; | ||||||
|  |   description?: Maybe<Scalars['String']>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type CreateArticleInput = { | ||||||
|  |   title: Scalars['String']; | ||||||
|  |   content: Scalars['String']; | ||||||
|  |   publishedAt?: Maybe<Scalars['DateTime']>; | ||||||
|  |   tags: Array<Scalars['String']>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type CreateDraftArticleInput = { | ||||||
|  |   payload: Scalars['Object']; | ||||||
|  |   key: Scalars['String']; | ||||||
|  |   automatically?: Maybe<Scalars['Boolean']>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type CreateTagInput = { | ||||||
|  |   name: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type DraftArticle = { | ||||||
|  |   __typename?: 'DraftArticle'; | ||||||
|  |   id: Scalars['ID']; | ||||||
|  |   payload: Scalars['Object']; | ||||||
|  |   key: Scalars['String']; | ||||||
|  |   automatically: Scalars['Boolean']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type Hello = { | ||||||
|  |   __typename?: 'Hello'; | ||||||
|  |   message: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type Mutation = { | ||||||
|  |   __typename?: 'Mutation'; | ||||||
|  |   createArticle: Article; | ||||||
|  |   updateArticle: Article; | ||||||
|  |   removeArticle: Scalars['Int']; | ||||||
|  |   putDraftForArticle: DraftArticle; | ||||||
|  |   listDraftForArticle: Array<DraftArticle>; | ||||||
|  |   lastDraftForArticle: DraftArticle; | ||||||
|  |   createTag: Tag; | ||||||
|  |   updateTag: Tag; | ||||||
|  |   removeTag: Tag; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationCreateArticleArgs = { | ||||||
|  |   createArticleInput: CreateArticleInput; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationUpdateArticleArgs = { | ||||||
|  |   updateArticleInput: UpdateArticleInput; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationRemoveArticleArgs = { | ||||||
|  |   id: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationPutDraftForArticleArgs = { | ||||||
|  |   draft: CreateDraftArticleInput; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationListDraftForArticleArgs = { | ||||||
|  |   key: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationLastDraftForArticleArgs = { | ||||||
|  |   key: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationCreateTagArgs = { | ||||||
|  |   createTagInput: CreateTagInput; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationUpdateTagArgs = { | ||||||
|  |   updateTagInput: UpdateTagInput; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type MutationRemoveTagArgs = { | ||||||
|  |   id: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type Query = { | ||||||
|  |   __typename?: 'Query'; | ||||||
|  |   hello: Hello; | ||||||
|  |   articles: Array<Article>; | ||||||
|  |   article: Article; | ||||||
|  |   tags: Array<Tag>; | ||||||
|  |   tag: Tag; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type QueryArticleArgs = { | ||||||
|  |   id: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export type QueryTagArgs = { | ||||||
|  |   id: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type Tag = { | ||||||
|  |   __typename?: 'Tag'; | ||||||
|  |   id: Scalars['ID']; | ||||||
|  |   name: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type UpdateArticleInput = { | ||||||
|  |   title?: Maybe<Scalars['String']>; | ||||||
|  |   content?: Maybe<Scalars['String']>; | ||||||
|  |   publishedAt?: Maybe<Scalars['DateTime']>; | ||||||
|  |   tags?: Maybe<Array<Scalars['String']>>; | ||||||
|  |   id: Scalars['String']; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type UpdateTagInput = { | ||||||
|  |   name?: Maybe<Scalars['String']>; | ||||||
|  |   id: Scalars['String']; | ||||||
|  | }; | ||||||
| @@ -11,3 +11,8 @@ code { | |||||||
|   font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', |   font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | ||||||
|     monospace; |     monospace; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | a { | ||||||
|  |   text-decoration: none; | ||||||
|  |   color: inherit; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,15 +1,32 @@ | |||||||
| import React from 'react'; | import React from "react"; | ||||||
| import ReactDOM from 'react-dom'; | import ReactDOM from "react-dom"; | ||||||
| import './index.css'; | import "./index.css"; | ||||||
| import "fontsource-roboto"; | import "fontsource-roboto"; | ||||||
| import App from './App'; | import App from "./App"; | ||||||
| import reportWebVitals from './reportWebVitals'; | import reportWebVitals from "./reportWebVitals"; | ||||||
|  | import { AppApolloClientProvider } from "./commons/graphql/client"; | ||||||
|  | import { MuiPickersUtilsProvider } from "@material-ui/pickers"; | ||||||
|  | import DateFnsUtils from "@date-io/date-fns"; | ||||||
|  | import zhLocale from "date-fns/locale/zh-CN"; | ||||||
|  | import { SnackbarProvider } from "notistack"; | ||||||
|  | import Router from "./commons/route/router"; | ||||||
|  | import { AuthProvider } from "./commons/auth/auth.provider"; | ||||||
|  |  | ||||||
| ReactDOM.render( | ReactDOM.render( | ||||||
|   <React.StrictMode> |   <React.StrictMode> | ||||||
|  |     <SnackbarProvider maxSnack={5}> | ||||||
|  |       <AuthProvider> | ||||||
|  |         <AppApolloClientProvider> | ||||||
|  |           <MuiPickersUtilsProvider utils={DateFnsUtils} locale={zhLocale}> | ||||||
|  |             <Router> | ||||||
|               <App /> |               <App /> | ||||||
|  |             </Router> | ||||||
|  |           </MuiPickersUtilsProvider> | ||||||
|  |         </AppApolloClientProvider> | ||||||
|  |       </AuthProvider> | ||||||
|  |     </SnackbarProvider> | ||||||
|   </React.StrictMode>, |   </React.StrictMode>, | ||||||
|   document.getElementById('root') |   document.getElementById("root") | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // If you want to start measuring performance in your app, pass a function | // If you want to start measuring performance in your app, pass a function | ||||||
|   | |||||||
							
								
								
									
										187
									
								
								src/layouts/default.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								src/layouts/default.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,187 @@ | |||||||
|  | import React, { FC } from "react"; | ||||||
|  | import clsx from "clsx"; | ||||||
|  | import { | ||||||
|  |   createStyles, | ||||||
|  |   makeStyles, | ||||||
|  |   useTheme, | ||||||
|  |   Theme, | ||||||
|  | } from "@material-ui/core/styles"; | ||||||
|  | import Drawer from "@material-ui/core/Drawer"; | ||||||
|  | import AppBar from "@material-ui/core/AppBar"; | ||||||
|  | import Toolbar from "@material-ui/core/Toolbar"; | ||||||
|  | import List from "@material-ui/core/List"; | ||||||
|  | import CssBaseline from "@material-ui/core/CssBaseline"; | ||||||
|  | import Typography from "@material-ui/core/Typography"; | ||||||
|  | import Divider from "@material-ui/core/Divider"; | ||||||
|  | import IconButton from "@material-ui/core/IconButton"; | ||||||
|  | import MenuIcon from "@material-ui/icons/Menu"; | ||||||
|  | import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; | ||||||
|  | import ChevronRightIcon from "@material-ui/icons/ChevronRight"; | ||||||
|  | import ListItem from "@material-ui/core/ListItem"; | ||||||
|  | import ListItemIcon from "@material-ui/core/ListItemIcon"; | ||||||
|  | import ListItemText from "@material-ui/core/ListItemText"; | ||||||
|  | import { AddCircle, Description, LocalOffer } from '@material-ui/icons'; | ||||||
|  | import { Link } from '@curi/react-dom'; | ||||||
|  | const drawerWidth = 240; | ||||||
|  |  | ||||||
|  | const useStyles = makeStyles((theme: Theme) => | ||||||
|  |   createStyles({ | ||||||
|  |     root: { | ||||||
|  |       display: "flex", | ||||||
|  |     }, | ||||||
|  |     appBar: { | ||||||
|  |       zIndex: theme.zIndex.drawer + 1, | ||||||
|  |       transition: theme.transitions.create(["width", "margin"], { | ||||||
|  |         easing: theme.transitions.easing.sharp, | ||||||
|  |         duration: theme.transitions.duration.leavingScreen, | ||||||
|  |       }), | ||||||
|  |     }, | ||||||
|  |     appBarShift: { | ||||||
|  |       marginLeft: drawerWidth, | ||||||
|  |       width: `calc(100% - ${drawerWidth}px)`, | ||||||
|  |       transition: theme.transitions.create(["width", "margin"], { | ||||||
|  |         easing: theme.transitions.easing.sharp, | ||||||
|  |         duration: theme.transitions.duration.enteringScreen, | ||||||
|  |       }), | ||||||
|  |     }, | ||||||
|  |     menuButton: { | ||||||
|  |       marginRight: 36, | ||||||
|  |     }, | ||||||
|  |     hide: { | ||||||
|  |       display: "none", | ||||||
|  |     }, | ||||||
|  |     drawer: { | ||||||
|  |       width: drawerWidth, | ||||||
|  |       flexShrink: 0, | ||||||
|  |       whiteSpace: "nowrap", | ||||||
|  |     }, | ||||||
|  |     drawerOpen: { | ||||||
|  |       width: drawerWidth, | ||||||
|  |       transition: theme.transitions.create("width", { | ||||||
|  |         easing: theme.transitions.easing.sharp, | ||||||
|  |         duration: theme.transitions.duration.enteringScreen, | ||||||
|  |       }), | ||||||
|  |     }, | ||||||
|  |     drawerClose: { | ||||||
|  |       transition: theme.transitions.create("width", { | ||||||
|  |         easing: theme.transitions.easing.sharp, | ||||||
|  |         duration: theme.transitions.duration.leavingScreen, | ||||||
|  |       }), | ||||||
|  |       overflowX: "hidden", | ||||||
|  |       width: theme.spacing(7) + 1, | ||||||
|  |       [theme.breakpoints.up("sm")]: { | ||||||
|  |         width: theme.spacing(9) + 1, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     toolbar: { | ||||||
|  |       display: "flex", | ||||||
|  |       alignItems: "center", | ||||||
|  |       justifyContent: "flex-end", | ||||||
|  |       padding: theme.spacing(0, 1), | ||||||
|  |       // necessary for content to be below app bar | ||||||
|  |       ...theme.mixins.toolbar, | ||||||
|  |     }, | ||||||
|  |     content: { | ||||||
|  |       flexGrow: 1, | ||||||
|  |       padding: theme.spacing(3), | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const DefaultLayout: FC = ({children}) => { | ||||||
|  |   const classes = useStyles(); | ||||||
|  |   const theme = useTheme(); | ||||||
|  |   const [open, setOpen] = React.useState(false); | ||||||
|  |  | ||||||
|  |   const handleDrawerOpen = () => { | ||||||
|  |     setOpen(true); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleDrawerClose = () => { | ||||||
|  |     setOpen(false); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={classes.root}> | ||||||
|  |         <CssBaseline /> | ||||||
|  |         <AppBar | ||||||
|  |           position="fixed" | ||||||
|  |           className={clsx(classes.appBar, { | ||||||
|  |             [classes.appBarShift]: open, | ||||||
|  |           })} | ||||||
|  |         > | ||||||
|  |           <Toolbar> | ||||||
|  |             <IconButton | ||||||
|  |               color="inherit" | ||||||
|  |               aria-label="open drawer" | ||||||
|  |               onClick={handleDrawerOpen} | ||||||
|  |               edge="start" | ||||||
|  |               className={clsx(classes.menuButton, { | ||||||
|  |                 [classes.hide]: open, | ||||||
|  |               })} | ||||||
|  |             > | ||||||
|  |               <MenuIcon /> | ||||||
|  |             </IconButton> | ||||||
|  |             <Typography variant="h6" noWrap> | ||||||
|  |               Mini variant drawer | ||||||
|  |             </Typography> | ||||||
|  |           </Toolbar> | ||||||
|  |         </AppBar> | ||||||
|  |         <Drawer | ||||||
|  |           variant="permanent" | ||||||
|  |           className={clsx(classes.drawer, { | ||||||
|  |             [classes.drawerOpen]: open, | ||||||
|  |             [classes.drawerClose]: !open, | ||||||
|  |           })} | ||||||
|  |           classes={{ | ||||||
|  |             paper: clsx({ | ||||||
|  |               [classes.drawerOpen]: open, | ||||||
|  |               [classes.drawerClose]: !open, | ||||||
|  |             }), | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <div className={classes.toolbar}> | ||||||
|  |             <IconButton onClick={handleDrawerClose}> | ||||||
|  |               {theme.direction === "rtl" ? ( | ||||||
|  |                 <ChevronRightIcon /> | ||||||
|  |               ) : ( | ||||||
|  |                 <ChevronLeftIcon /> | ||||||
|  |               )} | ||||||
|  |             </IconButton> | ||||||
|  |           </div> | ||||||
|  |           <Divider /> | ||||||
|  |           <List> | ||||||
|  |             <Link name="create-article"> | ||||||
|  |               <ListItem button> | ||||||
|  |                 <ListItemIcon> | ||||||
|  |                   <AddCircle /> | ||||||
|  |                 </ListItemIcon> | ||||||
|  |                 <ListItemText primary="New Article" /> | ||||||
|  |               </ListItem> | ||||||
|  |             </Link> | ||||||
|  |             <Link name="articles"> | ||||||
|  |               <ListItem button> | ||||||
|  |                 <ListItemIcon> | ||||||
|  |                   <Description /> | ||||||
|  |                 </ListItemIcon> | ||||||
|  |                 <ListItemText primary="Articles" /> | ||||||
|  |               </ListItem> | ||||||
|  |             </Link> | ||||||
|  |             <Link name="tags"> | ||||||
|  |               <ListItem button> | ||||||
|  |                 <ListItemIcon> | ||||||
|  |                   <LocalOffer /> | ||||||
|  |                 </ListItemIcon> | ||||||
|  |                 <ListItemText primary="Tags" /> | ||||||
|  |               </ListItem> | ||||||
|  |             </Link> | ||||||
|  |           </List> | ||||||
|  |           <Divider /> | ||||||
|  |         </Drawer> | ||||||
|  |         <main className={classes.content}> | ||||||
|  |           <div className={classes.toolbar} /> | ||||||
|  |           { children } | ||||||
|  |         </main> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								src/layouts/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/layouts/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './default'; | ||||||
							
								
								
									
										74
									
								
								src/routes.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/routes.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | |||||||
|  | import { ApolloClient } from "@apollo/client"; | ||||||
|  | import { prepareRoutes } from "@curi/router"; | ||||||
|  | import { omit } from 'ramda'; | ||||||
|  | import { ARTICLE } from "./articles"; | ||||||
|  | import { Article } from './generated/graphql'; | ||||||
|  |  | ||||||
|  | export default prepareRoutes([ | ||||||
|  |   { | ||||||
|  |     name: "dashboard", | ||||||
|  |     path: "", | ||||||
|  |     respond() { | ||||||
|  |       return { body: () => <div>DashBoard</div> }; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: "create-article", | ||||||
|  |     path: "articles/create", | ||||||
|  |     resolve() { | ||||||
|  |       const body = import( | ||||||
|  |         /* webpackChunkName: "article-editor" */ "./articles" | ||||||
|  |       ).then((m) => m.ArticleEditor); | ||||||
|  |       return body; | ||||||
|  |     }, | ||||||
|  |     respond({ resolved }) { | ||||||
|  |       return { body: resolved }; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: "modify-article", | ||||||
|  |     path: "articles/:id", | ||||||
|  |     async resolve(matched, { client }: { client: ApolloClient<any> }) { | ||||||
|  |       const [ArticleEditor, result] = await Promise.all([ | ||||||
|  |         import(/* webpackChunkName: "article-editor" */ "./articles").then( | ||||||
|  |           (m) => m.ArticleEditor | ||||||
|  |         ), | ||||||
|  |         client.query<{ article: Article }, { id: string }>({ | ||||||
|  |           query: ARTICLE, | ||||||
|  |           variables: { id: matched!.params.id }, | ||||||
|  |         }), | ||||||
|  |       ]); | ||||||
|  |       console.log(ArticleEditor, result); | ||||||
|  |       return () => ( | ||||||
|  |         <ArticleEditor article={omit(["__typename"], result.data.article)} /> | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     respond({ resolved }) { | ||||||
|  |       return { body: resolved }; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: "articles", | ||||||
|  |     path: "articles", | ||||||
|  |     resolve() { | ||||||
|  |       return import(/* webpackChunkName: "articles" */ "./articles").then( | ||||||
|  |         (m) => m.ArticleIndex | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     respond({ resolved }) { | ||||||
|  |       return { body: resolved }; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: "tags", | ||||||
|  |     path: "tags", | ||||||
|  |     resolve() { | ||||||
|  |       return import(/* webpackChunkName: "tags" */ "./tags").then( | ||||||
|  |         (m) => m.TagsIndex | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     respond({ resolved }) { | ||||||
|  |       return { body: resolved }; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | ]); | ||||||
							
								
								
									
										201
									
								
								src/tags/article-editor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								src/tags/article-editor.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | |||||||
|  | import { | ||||||
|  |   Button, | ||||||
|  |   createStyles, | ||||||
|  |   Grid, | ||||||
|  |   makeStyles, | ||||||
|  |   Paper, | ||||||
|  | } from "@material-ui/core"; | ||||||
|  | import { Field, Form, Formik, FormikHelpers } from "formik"; | ||||||
|  | import { FC } from "react"; | ||||||
|  | import { Editor } from "../commons/editor/vditor"; | ||||||
|  | import * as Yup from "yup"; | ||||||
|  | import { Article, CreateArticleInput, UpdateArticleInput } from "../generated/graphql"; | ||||||
|  | import { DateTimePicker } from "formik-material-ui-pickers"; | ||||||
|  | import { gql, useMutation } from "@apollo/client"; | ||||||
|  | import { useRouter } from "@curi/react-universal"; | ||||||
|  |  | ||||||
|  | const CREATE_ARTICLE = gql` | ||||||
|  |   mutation createArticle($createArticleInput: CreateArticleInput!) { | ||||||
|  |     createArticle(createArticleInput: $createArticleInput) { | ||||||
|  |       id | ||||||
|  |       title | ||||||
|  |       content | ||||||
|  |       publishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const UPDATE_ARTICLE = gql` | ||||||
|  |   mutation updateArticle($updateArticleInput: UpdateArticleInput!) { | ||||||
|  |     updateArticle(updateArticleInput: $updateArticleInput) { | ||||||
|  |       id | ||||||
|  |       title | ||||||
|  |       content | ||||||
|  |       publishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const useStyles = makeStyles((theme) => | ||||||
|  |   createStyles({ | ||||||
|  |     form: { | ||||||
|  |       padding: "15px 30px", | ||||||
|  |     }, | ||||||
|  |     editor: { | ||||||
|  |       height: "700px", | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | type Values = CreateArticleInput | UpdateArticleInput; | ||||||
|  | interface Props { | ||||||
|  |   article?: Values; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const ArticleEditor: FC<Props> = ({ article }) => { | ||||||
|  |   const classes = useStyles(); | ||||||
|  |  | ||||||
|  |   const validationSchema = Yup.object({ | ||||||
|  |     content: Yup.string() | ||||||
|  |       .max(65535, "文章内容不得超过 65535 个字符") | ||||||
|  |       .required("文章内容不得为空"), | ||||||
|  |     publishedAt: Yup.date(), | ||||||
|  |   }); | ||||||
|  |   const router = useRouter(); | ||||||
|  |  | ||||||
|  |   const [createArticle] = useMutation<{createArticle: Article} ,{ | ||||||
|  |     createArticleInput: CreateArticleInput; | ||||||
|  |   }>(CREATE_ARTICLE, { | ||||||
|  |     update(cache, { data }) { | ||||||
|  |       cache.modify({ | ||||||
|  |         fields: { | ||||||
|  |           articles(existingArticles = []) { | ||||||
|  |             const newArticleRef = cache.writeFragment({ | ||||||
|  |               data: data!.createArticle, | ||||||
|  |               fragment: gql` | ||||||
|  |                 fragment NewArticle on Article { | ||||||
|  |                   id | ||||||
|  |                   title | ||||||
|  |                   content | ||||||
|  |                   publishedAt | ||||||
|  |                 } | ||||||
|  |               `, | ||||||
|  |             }); | ||||||
|  |             return [newArticleRef, ...existingArticles]; | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |   const [updateArticle] = useMutation<{ | ||||||
|  |     updateArticleInput: UpdateArticleInput; | ||||||
|  |   }>(UPDATE_ARTICLE); | ||||||
|  |  | ||||||
|  |   const SubmitForm = ( | ||||||
|  |     values: Values, | ||||||
|  |     { setSubmitting }: FormikHelpers<Values> | ||||||
|  |   ) => { | ||||||
|  |     if ("id" in article!) { | ||||||
|  |       const title = /# (.+)$/m.exec(values.content ?? "")?.[1]; | ||||||
|  |       if (!title) { | ||||||
|  |         console.log("no title"); | ||||||
|  |         setSubmitting(false); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       updateArticle({ | ||||||
|  |         variables: { | ||||||
|  |           updateArticleInput: { | ||||||
|  |             ...values, | ||||||
|  |             title, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |         .then(() => { | ||||||
|  |           router.navigate({ | ||||||
|  |             url: router.url({ name: "articles" }), | ||||||
|  |             method: "replace", | ||||||
|  |           }); | ||||||
|  |         }) | ||||||
|  |         .finally(() => { | ||||||
|  |           setSubmitting(false); | ||||||
|  |         }); | ||||||
|  |     } else { | ||||||
|  |       const title = /# (.+)$/m.exec(values.content ?? "")?.[1]; | ||||||
|  |       if (!title) { | ||||||
|  |         console.log("no title"); | ||||||
|  |         setSubmitting(false); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       createArticle({ | ||||||
|  |         variables: { | ||||||
|  |           createArticleInput: { | ||||||
|  |             ...values as CreateArticleInput, | ||||||
|  |             title, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |         .then(() => { | ||||||
|  |           router.navigate({ | ||||||
|  |             url: router.url({ name: "articles" }), | ||||||
|  |             method: "replace", | ||||||
|  |           }); | ||||||
|  |         }) | ||||||
|  |         .finally(() => { | ||||||
|  |           setSubmitting(false); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |     setSubmitting(false); | ||||||
|  |   }; | ||||||
|  |   return ( | ||||||
|  |     <Paper> | ||||||
|  |       <Formik | ||||||
|  |         initialValues={article!} | ||||||
|  |         validationSchema={validationSchema} | ||||||
|  |         autoComplete="off" | ||||||
|  |         onSubmit={SubmitForm} | ||||||
|  |       > | ||||||
|  |         {({ submitForm, isSubmitting }) => ( | ||||||
|  |           <Form className={classes.form}> | ||||||
|  |             <Grid container spacing={3}> | ||||||
|  |               <Grid item xs={12}> | ||||||
|  |                 <Editor | ||||||
|  |                   className={classes.editor} | ||||||
|  |                   name="content" | ||||||
|  |                   label="Content" | ||||||
|  |                 /> | ||||||
|  |               </Grid> | ||||||
|  |               <Grid item xs={12}> | ||||||
|  |                 <Field | ||||||
|  |                   ampm={false} | ||||||
|  |                   component={DateTimePicker} | ||||||
|  |                   label="Published At" | ||||||
|  |                   name="publishedAt" | ||||||
|  |                   format="yyyy-MM-dd HH:mm:ss" | ||||||
|  |                 /> | ||||||
|  |               </Grid> | ||||||
|  |               <Grid item xs={12}> | ||||||
|  |                 <Button | ||||||
|  |                   variant="contained" | ||||||
|  |                   color="primary" | ||||||
|  |                   disabled={isSubmitting} | ||||||
|  |                   onClick={submitForm} | ||||||
|  |                 > | ||||||
|  |                   Submit | ||||||
|  |                 </Button> | ||||||
|  |               </Grid> | ||||||
|  |             </Grid> | ||||||
|  |           </Form> | ||||||
|  |         )} | ||||||
|  |       </Formik> | ||||||
|  |     </Paper> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | ArticleEditor.defaultProps = { | ||||||
|  |   article: { | ||||||
|  |     title: "", | ||||||
|  |     content: "", | ||||||
|  |     publishedAt: new Date(), | ||||||
|  |     tags: [], | ||||||
|  |   } as CreateArticleInput, | ||||||
|  | }; | ||||||
							
								
								
									
										3
									
								
								src/tags/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/tags/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export * from "./article-editor"; | ||||||
|  | export * from "./tags-index"; | ||||||
|  | export * from "./tags.constants"; | ||||||
							
								
								
									
										80
									
								
								src/tags/tags-index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/tags/tags-index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | |||||||
|  | import { useMutation, useQuery } from "@apollo/client"; | ||||||
|  | import { | ||||||
|  |   IconButton, | ||||||
|  |   Paper, | ||||||
|  |   Table, | ||||||
|  |   TableBody, | ||||||
|  |   TableCell, | ||||||
|  |   TableContainer, | ||||||
|  |   TableHead, | ||||||
|  |   TableRow, | ||||||
|  | } from "@material-ui/core"; | ||||||
|  | import { FC } from "react"; | ||||||
|  | import { Tag } from "../generated/graphql"; | ||||||
|  | import EditIcon from "@material-ui/icons/Edit"; | ||||||
|  | import { useRouter } from "@curi/react-dom"; | ||||||
|  | import { Delete } from "@material-ui/icons"; | ||||||
|  | import { REMOVE_TAG, TAGS } from "./tags.constants"; | ||||||
|  |  | ||||||
|  | export const TagsIndex: FC = () => { | ||||||
|  |   const { data } = useQuery<{ | ||||||
|  |     tags: Tag[]; | ||||||
|  |   }>(TAGS, {}); | ||||||
|  |  | ||||||
|  |   const [removeArticle] = useMutation<any, { id: string }>(REMOVE_TAG); | ||||||
|  |  | ||||||
|  |   const router = useRouter(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <section> | ||||||
|  |       <TableContainer component={Paper}> | ||||||
|  |         <Table aria-label="articles table"> | ||||||
|  |           <TableHead> | ||||||
|  |             <TableRow> | ||||||
|  |               <TableCell>Tag Name</TableCell> | ||||||
|  |               <TableCell>Views</TableCell> | ||||||
|  |               <TableCell>Articles</TableCell> | ||||||
|  |               <TableCell>Actions</TableCell> | ||||||
|  |             </TableRow> | ||||||
|  |           </TableHead> | ||||||
|  |           <TableBody> | ||||||
|  |             {data?.tags.map((tag) => ( | ||||||
|  |               <TableRow key={tag.id}> | ||||||
|  |                 <TableCell component="th" scope="row"> | ||||||
|  |                   {tag.name} | ||||||
|  |                 </TableCell> | ||||||
|  |                 <TableCell align="right"> -- </TableCell> | ||||||
|  |                 <TableCell align="right"> -- </TableCell> | ||||||
|  |                 <TableCell> | ||||||
|  |                   <IconButton | ||||||
|  |                     aria-label="edit" | ||||||
|  |                     onClick={() => | ||||||
|  |                       router.navigate({ | ||||||
|  |                         url: router.url({ | ||||||
|  |                           name: "modify-tag", | ||||||
|  |                           params: tag, | ||||||
|  |                         }), | ||||||
|  |                       }) | ||||||
|  |                     } | ||||||
|  |                   > | ||||||
|  |                     <EditIcon /> | ||||||
|  |                   </IconButton> | ||||||
|  |                   <IconButton | ||||||
|  |                     aria-label="delete" | ||||||
|  |                     onClick={() => | ||||||
|  |                       removeArticle({ | ||||||
|  |                         variables: tag, | ||||||
|  |                       }) | ||||||
|  |                     } | ||||||
|  |                   > | ||||||
|  |                     <Delete /> | ||||||
|  |                   </IconButton> | ||||||
|  |                 </TableCell> | ||||||
|  |               </TableRow> | ||||||
|  |             ))} | ||||||
|  |           </TableBody> | ||||||
|  |         </Table> | ||||||
|  |       </TableContainer> | ||||||
|  |     </section> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										16
									
								
								src/tags/tags.constants.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/tags/tags.constants.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | import { gql } from "@apollo/client"; | ||||||
|  |  | ||||||
|  | export const TAGS = gql` | ||||||
|  |   query Tags { | ||||||
|  |     tags { | ||||||
|  |       id | ||||||
|  |       name | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const REMOVE_TAG = gql` | ||||||
|  |   mutation RemoveTag($id: ID!) { | ||||||
|  |     removeTag(id: $id) | ||||||
|  |   } | ||||||
|  | `; | ||||||
							
								
								
									
										22
									
								
								src/utils/deep-omit.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/utils/deep-omit.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | import { fromPairs, map, omit, pipe, toPairs, type } from "ramda"; | ||||||
|  |  | ||||||
|  | export const deepOmit = <T = any, K = any>( | ||||||
|  |   names: readonly string[], | ||||||
|  |   value: K | ||||||
|  | ): T => { | ||||||
|  |   switch (type(value)) { | ||||||
|  |     case "Array": | ||||||
|  |       return (value as unknown as Array<any>).map((item: any) => | ||||||
|  |         deepOmit(names, item) | ||||||
|  |       ) as unknown as T; | ||||||
|  |     case "Object": | ||||||
|  |       return pipe( | ||||||
|  |         omit(names), | ||||||
|  |         toPairs, | ||||||
|  |         map(([key, val]) => [key, deepOmit(names, val)] as any), | ||||||
|  |         fromPairs | ||||||
|  |       )(value) as unknown as T; | ||||||
|  |     default: | ||||||
|  |       return value as unknown as T; | ||||||
|  |   } | ||||||
|  | }; | ||||||
		Reference in New Issue
	
	Block a user