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": [ | ||||
|     "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, | ||||
|   "dependencies": { | ||||
|     "@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/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/react": "^11.2.6", | ||||
|     "@testing-library/user-event": "^12.8.3", | ||||
| @@ -13,19 +22,36 @@ | ||||
|     "@types/node": "^12.20.10", | ||||
|     "@types/react": "^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", | ||||
|     "formik": "^2.2.6", | ||||
|     "formik-material-ui": "^3.0.1", | ||||
|     "formik-material-ui-pickers": "^0.0.12", | ||||
|     "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-dom": "^17.0.2", | ||||
|     "react-scripts": "4.0.3", | ||||
|     "subscriptions-transport-ws": "^0.9.19", | ||||
|     "typescript": "^4.2.4", | ||||
|     "web-vitals": "^1.1.1" | ||||
|     "vditor": "^3.8.4", | ||||
|     "web-vitals": "^1.1.1", | ||||
|     "yup": "^0.32.9" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "react-scripts start", | ||||
|     "build": "react-scripts build", | ||||
|     "test": "react-scripts test", | ||||
|     "eject": "react-scripts eject" | ||||
|     "start": "craco start", | ||||
|     "build": "craco build", | ||||
|     "test": "craco test", | ||||
|     "prestart": "npm run graphql", | ||||
|     "graphql": "graphql-codegen --config codegen.yml" | ||||
|   }, | ||||
|   "eslintConfig": { | ||||
|     "extends": [ | ||||
| @@ -46,6 +72,15 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "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 logo from './logo.svg'; | ||||
| import { useResponse } from '@curi/react-dom'; | ||||
| import './App.css'; | ||||
| import { Button } from '@material-ui/core'; | ||||
| import { DefaultLayout } from './layouts'; | ||||
|  | ||||
| function App() { | ||||
| const { response } = useResponse(); | ||||
|  | ||||
| const { body: Body } = response; | ||||
|   return ( | ||||
|     <Button variant="contained" color="primary"> | ||||
|       你好,世界 | ||||
|     </Button> | ||||
|     <DefaultLayout> | ||||
|       <Body response={response} /> | ||||
|     </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', | ||||
|     monospace; | ||||
| } | ||||
|  | ||||
| a { | ||||
|   text-decoration: none; | ||||
|   color: inherit; | ||||
| } | ||||
|   | ||||
| @@ -1,15 +1,32 @@ | ||||
| import React from 'react'; | ||||
| import ReactDOM from 'react-dom'; | ||||
| import './index.css'; | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom"; | ||||
| import "./index.css"; | ||||
| import "fontsource-roboto"; | ||||
| import App from './App'; | ||||
| import reportWebVitals from './reportWebVitals'; | ||||
| import App from "./App"; | ||||
| 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( | ||||
|   <React.StrictMode> | ||||
|     <SnackbarProvider maxSnack={5}> | ||||
|       <AuthProvider> | ||||
|         <AppApolloClientProvider> | ||||
|           <MuiPickersUtilsProvider utils={DateFnsUtils} locale={zhLocale}> | ||||
|             <Router> | ||||
|               <App /> | ||||
|             </Router> | ||||
|           </MuiPickersUtilsProvider> | ||||
|         </AppApolloClientProvider> | ||||
|       </AuthProvider> | ||||
|     </SnackbarProvider> | ||||
|   </React.StrictMode>, | ||||
|   document.getElementById('root') | ||||
|   document.getElementById("root") | ||||
| ); | ||||
|  | ||||
| // 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