├── .gitignore ├── LICENSE.md ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── _redirects ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── ApolloWrapper.tsx ├── App.test.jsx ├── App.tsx ├── RouterWrapper.tsx ├── auth │ ├── auth-config.js │ └── react-auth0-wrapper.js ├── components │ ├── Editor │ │ ├── CodeBlock.tsx │ │ ├── Hyperlink.tsx │ │ ├── Leaf.tsx │ │ ├── Link.tsx │ │ ├── List.tsx │ │ ├── ListItem.tsx │ │ ├── TextWrapper.tsx │ │ └── index.tsx │ ├── Link.tsx │ ├── Page.tsx │ ├── PageList.tsx │ └── PagesTooltip.tsx ├── fonts │ ├── Montserrat-Black.ttf │ ├── Montserrat-Bold.ttf │ └── Montserrat-Regular.ttf ├── index.css ├── index.tsx ├── logo.svg ├── machines │ ├── appMachine.ts │ └── page │ │ ├── editingLinkState.ts │ │ ├── events.js │ │ ├── index.ts │ │ ├── loadingState.ts │ │ ├── selectedListItemState.ts │ │ └── syncState.ts ├── mutations │ ├── deleteLinks.ts │ ├── getOrCreatePage.ts │ ├── upsertLinks.ts │ └── upsertPage.ts ├── plugins │ ├── withCodeBlockListItems.ts │ ├── withHelpers.ts │ ├── withHyperlinks.ts │ ├── withLinks.ts │ ├── withList.ts │ └── withSoftBreaks.ts ├── queries │ ├── getLinksByValue.ts │ ├── getPages.ts │ └── getPagesByTitle.ts ├── react-app-env.d.ts ├── serviceWorker.ts ├── setupTests.ts ├── styles │ ├── index.css │ └── tailwind.css └── utils │ ├── array.ts │ └── datetime.ts ├── tailwind.config.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # api 12 | api.js 13 | 14 | # production 15 | /build 16 | 17 | # env 18 | 19 | # misc 20 | .DS_Store 21 | .env 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # Local Netlify folder 32 | .netlify -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dane Burkland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | A bidirectional note taking app inspired by Roam Research. [Live demo](https://linked-notes.netlify.app/) 4 | 5 | Context behind this project: https://www.afforded.space/note-taking-in-public/ 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ## Setting up a GraphQL server 16 | 17 | To set up a Hasura GraphQL API + Postgres database (with correct schema) on Heroku's free tier, first follow the one click deploy [here](https://hasura.io/docs/1.0/graphql/manual/getting-started/heroku-simple.html). 18 | 19 | After the Heroku app has finished deploying, click the 'View' button. Your GraphQL endpoint will be listed at the top of the 'GraphiQL' tab. 20 | 21 | To allow your app to talk to your backend, you'll need to set a `REACT_APP_GRAPHQL_ENDPOINT_DEV` (`REACT_APP_GRAPHQL_ENDPOINT_PROD`) environment variable with that endpoint. 22 | 23 | Finally, copy the correct schema to your Postgres database: 24 | 25 | `heroku pg:backups:restore https://notes-schema.s3.amazonaws.com/latest.dump DATABASE --app ` 26 | 27 | ## Deployment 28 | 29 | If you'd like to deploy your app, Netlify is a great option: 30 | 31 | ``` 32 | npm run build 33 | netlify deploy 34 | ``` 35 | 36 | ## Authentication 37 | 38 | This app is set up to use Auth0 as an optional authentication provider. To secure your GraphQL endpoints, follow [these](https://hasura.io/docs/1.0/graphql/manual/deployment/heroku/securing-graphql-endpoint.html#heroku-secure) steps. Next, follow [these](https://hasura.io/docs/1.0/graphql/manual/guides/integrations/auth0-jwt.html) Auth0 integration steps. Lastly, you'll need to set the appropriate environment variables: `REACT_APP_AUTH0_DOMAIN`, `REACT_APP_AUTH0_CLIENTID`, `REACT_APP_AUTH0_REDIRECT_URI_PROD` (`REACT_APP_AUTH0_REDIRECT_URI_DEV`). Permissions can then be assigned by role in the Hasura GUI. 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notes", 3 | "author": "Dane Burkland ", 4 | "version": "0.1.0", 5 | "private": true, 6 | "dependencies": { 7 | "@apollo/react-hooks": "^3.1.5", 8 | "@apollo/react-testing": "^3.1.4", 9 | "@auth0/auth0-spa-js": "^1.8.1", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.5.0", 12 | "@testing-library/user-event": "^7.2.1", 13 | "@types/classnames": "^2.2.10", 14 | "@types/jest": "^24.9.1", 15 | "@types/node": "^12.12.35", 16 | "@types/react": "^16.9.34", 17 | "@types/react-dom": "^16.9.6", 18 | "@types/react-router-dom": "^5.1.4", 19 | "@types/route-parser": "^0.1.3", 20 | "@types/slate-react": "^0.22.9", 21 | "@types/uuid": "^7.0.2", 22 | "@xstate/react": "^0.8.1", 23 | "@xstate/test": "^0.4.0", 24 | "apollo-boost": "^0.4.7", 25 | "apollo-link-context": "^1.0.20", 26 | "classnames": "^2.2.6", 27 | "date-fns": "^2.12.0", 28 | "graphql": "^15.0.0", 29 | "history": "^4.10.1", 30 | "postcss-cli": "^7.1.0", 31 | "react": "^16.13.1", 32 | "react-dom": "^16.13.1", 33 | "react-router-dom": "^5.1.2", 34 | "react-scripts": "3.4.1", 35 | "route-parser": "0.0.5", 36 | "rxjs": "^6.5.5", 37 | "slate": "^0.57.2", 38 | "slate-history": "^0.58.0", 39 | "slate-react": "^0.57.2", 40 | "tailwindcss": "^1.2.0", 41 | "typescript": "^3.7.5", 42 | "uuid": "^7.0.3", 43 | "xstate": "^4.9.1" 44 | }, 45 | "scripts": { 46 | "build:css": "postcss src/styles/tailwind.css -o src/styles/index.css", 47 | "watch:css": "postcss src/styles/tailwind.css -o src/styles/index.css -w", 48 | "start": "npm run watch:css & react-scripts start", 49 | "build": "npm run build:css && react-scripts build", 50 | "test": "react-scripts test", 51 | "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache", 52 | "eject": "react-scripts eject" 53 | }, 54 | "eslintConfig": { 55 | "extends": "react-app" 56 | }, 57 | "devDependencies": { 58 | "autoprefixer": "^9.7.6" 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | module.exports = { 3 | plugins: [tailwindcss("./tailwind.config.js"), require("autoprefixer")], 4 | }; 5 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneburkland/notes/a841d7f0bb7f0ab324ac11e017728ff11717536d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Notes 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneburkland/notes/a841d7f0bb7f0ab324ac11e017728ff11717536d/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneburkland/notes/a841d7f0bb7f0ab324ac11e017728ff11717536d/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/ApolloWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ReactElement, useEffect, useCallback } from "react"; 2 | import ApolloClient from "apollo-boost"; 3 | import { OperationVariables } from "@apollo/react-common"; 4 | import { ApolloProvider } from "@apollo/react-hooks"; 5 | import { DocumentNode } from "graphql"; 6 | import { useAuth0 } from "./auth/react-auth0-wrapper"; 7 | 8 | const uri = 9 | process.env.NODE_ENV === "production" 10 | ? process.env.REACT_APP_GRAPHQL_ENDPOINT_PROD 11 | : process.env.REACT_APP_GRAPHQL_ENDPOINT_DEV; 12 | 13 | export const ApolloClientContext = React.createContext({ 14 | useLazyQuery: null as any, 15 | }); 16 | 17 | // TODO: make this a machine 18 | function ApolloWrapper({ children }: { children: ReactElement }) { 19 | const { getTokenSilently, loading } = useAuth0(); 20 | 21 | const [accessToken, setAccessToken] = useState(); 22 | 23 | const getAccessToken = useCallback(async () => { 24 | try { 25 | const token = await getTokenSilently(); 26 | setAccessToken(token); 27 | } catch (e) { 28 | console.log(e); 29 | } 30 | }, [getTokenSilently]); 31 | 32 | useEffect(() => { 33 | !loading && getAccessToken(); 34 | }, [loading, getAccessToken]); 35 | 36 | const client = new ApolloClient({ 37 | uri, 38 | request: (operation) => { 39 | const headers = !!accessToken 40 | ? { authorization: `Bearer ${accessToken}` } 41 | : {}; 42 | operation.setContext({ 43 | headers, 44 | }); 45 | }, 46 | }); 47 | 48 | function useLazyQuery( 49 | query: DocumentNode 50 | ) { 51 | return (variables: TVariables) => 52 | client.query({ 53 | query: query, 54 | variables: variables, 55 | }); 56 | } 57 | 58 | return ( 59 | 60 | 61 | {children} 62 | 63 | 64 | ); 65 | } 66 | 67 | export default ApolloWrapper; 68 | -------------------------------------------------------------------------------- /src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, findByText, cleanup } from "@testing-library/react"; 3 | import { Router } from "react-router-dom"; 4 | import { App } from "./App"; 5 | import { MockedProvider } from "@apollo/react-testing"; 6 | import GET_OR_CREATE_PAGE from "./mutations/getOrCreatePage"; 7 | import GET_PAGE from "./queries/getPages"; 8 | import { createBrowserHistory } from "history"; 9 | import { todayDateString } from "./utils/datetime"; 10 | import GET_LINKS_BY_VALUE from "./queries/getLinksByValue"; 11 | import { placeholderNode } from "./plugins/withLinks"; 12 | 13 | const rootMocks = [ 14 | { 15 | request: { 16 | query: GET_OR_CREATE_PAGE, 17 | variables: { 18 | page: { 19 | title: todayDateString(), 20 | node: placeholderNode, 21 | }, 22 | }, 23 | }, 24 | result: { 25 | data: { 26 | insert_page: { 27 | returning: [{ node: placeholderNode, title: todayDateString() }], 28 | }, 29 | }, 30 | }, 31 | }, 32 | 33 | { 34 | request: { 35 | query: GET_LINKS_BY_VALUE, 36 | variables: { value: todayDateString() }, 37 | }, 38 | result: { 39 | data: { 40 | link: [], 41 | }, 42 | }, 43 | }, 44 | ]; 45 | 46 | test("root", async () => { 47 | window.getSelection = function () { 48 | return { 49 | removeAllRanges: function () {}, 50 | }; 51 | }; 52 | 53 | const history = createBrowserHistory(); 54 | history.push({ pathname: "/" }); 55 | const { container } = render( 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | 63 | const titleElement = await findByText(container, todayDateString()); 64 | expect(titleElement).toBeInTheDocument(); 65 | 66 | // it("title element is present", async () => { 67 | // const titleElement = await findByText(container, todayDateString()); 68 | // expect(titleElement).toBeInTheDocument(); 69 | // }); 70 | 71 | cleanup(); 72 | }); 73 | 74 | const pageMocks = [ 75 | { 76 | request: { 77 | query: GET_OR_CREATE_PAGE, 78 | variables: { 79 | page: { 80 | title: "yay", 81 | node: placeholderNode, 82 | }, 83 | }, 84 | }, 85 | result: { 86 | data: { 87 | insert_page: { 88 | returning: [{ node: placeholderNode, title: "yay" }], 89 | }, 90 | }, 91 | }, 92 | }, 93 | 94 | { 95 | request: { 96 | query: GET_LINKS_BY_VALUE, 97 | variables: { value: "yay" }, 98 | }, 99 | result: { 100 | data: { 101 | link: [], 102 | }, 103 | }, 104 | }, 105 | ]; 106 | 107 | test("page", async () => { 108 | window.getSelection = function () { 109 | return { 110 | removeAllRanges: function () {}, 111 | }; 112 | }; 113 | 114 | const history = createBrowserHistory(); 115 | history.push({ pathname: "/page/yay" }); 116 | const { container } = render( 117 | 118 | 119 | 120 | 121 | 122 | ); 123 | 124 | const titleElement = await findByText(container, "yay"); 125 | expect(titleElement).toBeInTheDocument(); 126 | 127 | cleanup(); 128 | }); 129 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Switch, Route, Link, useHistory } from "react-router-dom"; 3 | import Page from "./components/Page"; 4 | import PageList from "./components/PageList"; 5 | import { useMachine } from "@xstate/react"; 6 | import appMachine from "./machines/appMachine"; 7 | import UPSERT_LINKS from "./mutations/upsertLinks"; 8 | import UPSERT_PAGE from "./mutations/upsertPage"; 9 | import DELETE_LINKS from "./mutations/deleteLinks"; 10 | import GET_OR_CREATE_PAGE from "./mutations/getOrCreatePage"; 11 | import GET_LINKS_BY_VALUE from "./queries/getLinksByValue"; 12 | import { useMutation } from "@apollo/react-hooks"; 13 | import { fromEventPattern } from "rxjs"; 14 | import { Interpreter } from "xstate"; 15 | import { IContext } from "./machines/page"; 16 | import GET_PAGES_BY_TITLE from "./queries/getPagesByTitle"; 17 | import { useAuth0 } from "./auth/react-auth0-wrapper"; 18 | import { ApolloClientContext } from "./ApolloWrapper"; 19 | 20 | export const AppContext = React.createContext({ 21 | state: {}, 22 | send: (obj: any) => obj, 23 | page: {} as Interpreter | null, 24 | }); 25 | 26 | export function App() { 27 | const { useLazyQuery } = useContext(ApolloClientContext); 28 | const [upsertLinks] = useMutation(UPSERT_LINKS); 29 | const [upsertPage] = useMutation(UPSERT_PAGE); 30 | const [deleteLinks] = useMutation(DELETE_LINKS); 31 | const getLinksByValue = useLazyQuery(GET_LINKS_BY_VALUE); 32 | const getPagesByTitle = useLazyQuery(GET_PAGES_BY_TITLE); 33 | const [getOrCreatePage] = useMutation(GET_OR_CREATE_PAGE); 34 | const history = useHistory(); 35 | const history$ = fromEventPattern(history.listen); 36 | 37 | const [state, send] = useMachine(appMachine, { 38 | context: { 39 | upsertLinks, 40 | upsertPage, 41 | deleteLinks, 42 | getOrCreatePage, 43 | getLinksByValue, 44 | getPagesByTitle, 45 | history$, 46 | }, 47 | }); 48 | const { page } = state.context; 49 | const { loginWithRedirect, isAuthenticated } = useAuth0(); 50 | 51 | return ( 52 | 53 |
    54 |
  • 55 | 60 | notes 61 | 62 |
  • 63 | {!process.env.REACT_APP_IS_DEMO && !isAuthenticated && ( 64 | 70 | )} 71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 | ); 85 | } 86 | 87 | function AppWrapper() { 88 | return ( 89 |
90 | 91 |
92 | ); 93 | } 94 | 95 | export default AppWrapper; 96 | -------------------------------------------------------------------------------- /src/RouterWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { Router } from "react-router-dom"; 3 | import { createBrowserHistory } from "history"; 4 | 5 | function RouterWrapper({ children }: { children: ReactElement }) { 6 | const history = createBrowserHistory(); 7 | return {children}; 8 | } 9 | 10 | export default RouterWrapper; 11 | -------------------------------------------------------------------------------- /src/auth/auth-config.js: -------------------------------------------------------------------------------- 1 | const devConfig = { 2 | redirect_uri: process.env.REACT_APP_AUTH0_REDIRECT_URI_DEV, 3 | audience: process.env.REACT_APP_GRAPHQL_ENDPOINT_DEV, 4 | }; 5 | 6 | const prodConfig = { 7 | redirect_uri: process.env.REACT_APP_AUTH0_REDIRECT_URI_PROD, 8 | audience: process.env.REACT_APP_GRAPHQL_ENDPOINT_PROD, 9 | }; 10 | 11 | export default { 12 | domain: process.env.REACT_APP_AUTH0_DOMAIN, 13 | clientId: process.env.REACT_APP_AUTH0_CLIENTID, 14 | ...(process.env.NODE_ENV === "production" ? prodConfig : devConfig), 15 | }; 16 | -------------------------------------------------------------------------------- /src/auth/react-auth0-wrapper.js: -------------------------------------------------------------------------------- 1 | // src/react-auth0-spa.js 2 | import React, { useState, useEffect, useContext } from "react"; 3 | import createAuth0Client from "@auth0/auth0-spa-js"; 4 | 5 | const DEFAULT_REDIRECT_CALLBACK = () => 6 | window.history.replaceState({}, document.title, window.location.pathname); 7 | 8 | export const Auth0Context = React.createContext(); 9 | export const useAuth0 = () => useContext(Auth0Context); 10 | export const Auth0Provider = ({ 11 | children, 12 | onRedirectCallback = DEFAULT_REDIRECT_CALLBACK, 13 | ...initOptions 14 | }) => { 15 | const [isAuthenticated, setIsAuthenticated] = useState(); 16 | const [user, setUser] = useState(); 17 | const [auth0Client, setAuth0] = useState(); 18 | const [loading, setLoading] = useState(true); 19 | const [popupOpen, setPopupOpen] = useState(false); 20 | 21 | useEffect(() => { 22 | const initAuth0 = async () => { 23 | const auth0FromHook = await createAuth0Client(initOptions); 24 | setAuth0(auth0FromHook); 25 | 26 | if ( 27 | window.location.search.includes("code=") && 28 | window.location.search.includes("state=") 29 | ) { 30 | const { appState } = await auth0FromHook.handleRedirectCallback(); 31 | onRedirectCallback(appState); 32 | } 33 | 34 | const isAuthenticated = await auth0FromHook.isAuthenticated(); 35 | 36 | setIsAuthenticated(isAuthenticated); 37 | 38 | if (isAuthenticated) { 39 | const user = await auth0FromHook.getUser(); 40 | setUser(user); 41 | } 42 | 43 | setLoading(false); 44 | }; 45 | initAuth0(); 46 | // eslint-disable-next-line 47 | }, []); 48 | 49 | const loginWithPopup = async (params = {}) => { 50 | setPopupOpen(true); 51 | try { 52 | await auth0Client.loginWithPopup(params); 53 | } catch (error) { 54 | console.error(error); 55 | } finally { 56 | setPopupOpen(false); 57 | } 58 | const user = await auth0Client.getUser(); 59 | setUser(user); 60 | setIsAuthenticated(true); 61 | }; 62 | 63 | const handleRedirectCallback = async () => { 64 | setLoading(true); 65 | await auth0Client.handleRedirectCallback(); 66 | const user = await auth0Client.getUser(); 67 | setLoading(false); 68 | setIsAuthenticated(true); 69 | setUser(user); 70 | }; 71 | return ( 72 | auth0Client.getIdTokenClaims(...p), 81 | loginWithRedirect: (...p) => auth0Client.loginWithRedirect(...p), 82 | getTokenSilently: (...p) => auth0Client.getTokenSilently(...p), 83 | getTokenWithPopup: (...p) => auth0Client.getTokenWithPopup(...p), 84 | logout: (...p) => auth0Client.logout(...p), 85 | }} 86 | > 87 | {children} 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/Editor/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function (props: any) { 4 | return ( 5 |
 6 |       {props.children}
 7 |     
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Editor/Hyperlink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classnames from "classnames"; 3 | 4 | export default function (props: any) { 5 | return ( 6 | window.open(props.element.url)} 9 | className={classnames("cursor-pointer")} 10 | {...props.attributes} 11 | > 12 | {props.children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Editor/Leaf.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function (props: any) { 4 | return ( 5 | 9 | {props.children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Editor/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import classnames from "classnames"; 4 | import { PageContext } from "../Page"; 5 | 6 | export default function (props: any) { 7 | const { element } = props; 8 | const { touchedLinkNodes } = useContext(PageContext); 9 | const touchedIds = touchedLinkNodes.map((node: any) => node.id); 10 | 11 | const history = useHistory(); 12 | 13 | // it's possible for a link to be in process of creation, user refresh 14 | // we don't want to make those clickable 15 | if (touchedIds.includes(element.id) || element.isIncomplete) { 16 | return ( 17 | 18 | {props.children} 19 | 20 | ); 21 | } 22 | 23 | // TODO: readOnly something like this 24 | // const mungedChildren = { ...props.children }; 25 | // const textNode = mungedChildren.props.node.children[0]; 26 | // mungedChildren.props.node.children[0] = { 27 | // text: textNode.text.slice(2, textNode.text.length - 2), 28 | // }; 29 | 30 | return ( 31 | 33 | history.push(`/page/${encodeURIComponent(element.value)}`) 34 | } 35 | className={classnames("cursor-pointer", { 36 | "text-gray-500": element.touched, 37 | })} 38 | {...props.attributes} 39 | > 40 | {props.children} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Editor/List.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function (props: any) { 4 | return ( 5 |
    6 | {props.children} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Editor/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function (props: any) { 4 | return ( 5 |
  • 6 | {props.children} 7 |
  • 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Editor/TextWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function (props: any) { 4 | return ( 5 | 6 | {props.children} 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Leaf from "./Leaf"; 4 | import ListItem from "./ListItem"; 5 | import CodeBlock from "./CodeBlock"; 6 | import List from "./List"; 7 | import TextWrapper from "./TextWrapper"; 8 | import Hyperlink from "./Hyperlink"; 9 | import Link from "./Link"; 10 | import { Slate, Editable } from "slate-react"; 11 | 12 | interface ListItem { 13 | type: string; 14 | children: any; 15 | } 16 | 17 | const renderElement = (props: any) => { 18 | switch (props.element.type) { 19 | case "hyperlink": 20 | return ; 21 | case "list": 22 | return ; 23 | case "link": 24 | return ; 25 | case "codeBlock": 26 | return ; 27 | case "text-wrapper": 28 | return ; 29 | case "list-item": 30 | default: 31 | return ; 32 | } 33 | }; 34 | 35 | const renderLeaf = (props: any) => ; 36 | 37 | function Editor({ 38 | value, 39 | onChange, 40 | onKeyDown, 41 | editor, 42 | readOnly, 43 | title, 44 | className, 45 | }: any) { 46 | return ( 47 |
    48 | 49 | 56 | 57 |
    58 | ); 59 | } 60 | 61 | export default Editor; 62 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import Editor from "./Editor"; 5 | import { createEditor } from "slate"; 6 | import withLink from "../plugins/withLinks"; 7 | import { withReact } from "slate-react"; 8 | 9 | function wrapInList(node: any) { 10 | return { 11 | type: "list", 12 | children: [node], 13 | }; 14 | } 15 | 16 | function LinkNode({ data }: any) { 17 | const editor = useMemo(() => withLink(withReact(createEditor())), []); 18 | 19 | return ( 20 |
    21 | 22 |

    {data.pageTitle}

    23 | 24 | 30 |
    31 | ); 32 | } 33 | 34 | export default LinkNode; 35 | -------------------------------------------------------------------------------- /src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useService } from "@xstate/react"; 3 | import Editor from "./Editor"; 4 | import LinkNode from "./Link"; 5 | import { NodeEntry } from "slate"; 6 | import { KEY_DOWN, CHANGE } from "../machines/page/events"; 7 | import PagesTooltip from "./PagesTooltip"; 8 | 9 | import { useQuery } from "@apollo/react-hooks"; 10 | import GET_LINKS_BY_VALUE from "../queries/getLinksByValue"; 11 | import { IContext, IEvent } from "../machines/page"; 12 | import { useAuth0 } from "../auth/react-auth0-wrapper"; 13 | 14 | export const PageContext = React.createContext({ 15 | activeLinkId: "", 16 | touchedLinkNodes: [] as any, 17 | }); 18 | 19 | function Page({ page: pageMachine }: { page: any }) { 20 | const [current, send] = useService(pageMachine as any); 21 | const { isAuthenticated } = useAuth0(); 22 | 23 | const { 24 | context: { title }, 25 | } = current; 26 | const { data: linkData, loading: linksLoading } = useQuery( 27 | GET_LINKS_BY_VALUE, 28 | { 29 | variables: { value: title }, 30 | fetchPolicy: "network-only", 31 | } 32 | ); 33 | 34 | const handleKeyDown = (event: KeyboardEvent) => { 35 | if (["Tab", "Enter"].includes(event.key)) { 36 | event.preventDefault(); 37 | } 38 | if (event.key === "Backspace" && !current.context.canBackspace) { 39 | event.preventDefault(); 40 | } 41 | 42 | send({ 43 | type: KEY_DOWN, 44 | key: event.key, 45 | shiftKey: event.shiftKey, 46 | metaKey: event.metaKey, 47 | }); 48 | }; 49 | 50 | const handleChange = (value: NodeEntry[]) => { 51 | send({ type: CHANGE, value }); 52 | }; 53 | 54 | if (current.matches("loading")) { 55 | return
    Waking up free Heroku dynos...
    ; 56 | } 57 | 58 | const isSynced = current.matches({ loaded: { sync: "synced" } }); 59 | 60 | return ( 61 |
    62 |
    63 |
    64 |

    65 | {current.context.title} 66 |

    67 | 68 | 69 | 70 |
    71 | {!!current.context.errorMessage && ( 72 | {current.context.errorMessage} 73 | )} 74 | 80 | 88 | 89 | 90 |
    91 |
    92 |

    References

    93 | {linksLoading ? ( 94 |
    Loading references...
    95 | ) : ( 96 | linkData.link.map((link: any) => ( 97 | 98 | )) 99 | )} 100 |
    101 |
    102 | {process.env.REACT_APP_AUTHOR && ( 103 |
    104 | 105 | The working notes of{" "} 106 | 107 | 108 | {process.env.REACT_APP_AUTHOR} 109 | 110 | 111 | 112 |
    113 | )} 114 |
    115 | ); 116 | } 117 | 118 | export default Page; 119 | -------------------------------------------------------------------------------- /src/components/PageList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQuery } from "@apollo/react-hooks"; 3 | import { Link } from "react-router-dom"; 4 | import GET_PAGES from "../queries/getPages"; 5 | import { formatRelative } from "date-fns"; 6 | 7 | function PageList() { 8 | const { data, loading } = useQuery(GET_PAGES, { 9 | fetchPolicy: "network-only", 10 | }); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {!loading && 22 | data.page.map((page: any) => ( 23 | 24 | 31 | 34 | 35 | ))} 36 | 37 |
    TitleLast updated
    25 | { 26 | 27 | {page.title} 28 | 29 | } 30 | 32 | {formatRelative(new Date(page.updated_at), new Date())} 33 |
    38 | ); 39 | } 40 | 41 | export default PageList; 42 | -------------------------------------------------------------------------------- /src/components/PagesTooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import classnames from "classnames"; 4 | import { SELECT_LINK } from "../machines/page/events"; 5 | 6 | const Portal = ({ children }: { children: ReactElement }) => { 7 | return ReactDOM.createPortal(children, document.body); 8 | }; 9 | 10 | function PagesTooltip({ send, current }: any) { 11 | const isEditingLink = 12 | current.matches({ 13 | loaded: { editingLink: { editing: { data: "idle" } } }, 14 | }) || 15 | current.matches({ 16 | loaded: { editingLink: { editing: { data: "loading" } } }, 17 | }); 18 | 19 | const hasMatches = !!current.context.filteredPages.length; 20 | return ( 21 | 22 |
    30 | {current.context.filteredPages.map((node: any) => { 31 | return ( 32 |
    send({ type: SELECT_LINK, node })} 36 | > 37 | {node.title} 38 |
    39 | ); 40 | })} 41 |
    42 |
    43 | ); 44 | } 45 | 46 | export default PagesTooltip; 47 | -------------------------------------------------------------------------------- /src/fonts/Montserrat-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneburkland/notes/a841d7f0bb7f0ab324ac11e017728ff11717536d/src/fonts/Montserrat-Black.ttf -------------------------------------------------------------------------------- /src/fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneburkland/notes/a841d7f0bb7f0ab324ac11e017728ff11717536d/src/fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /src/fonts/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneburkland/notes/a841d7f0bb7f0ab324ac11e017728ff11717536d/src/fonts/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Montserrat"; 3 | src: url("./fonts/Montserrat-Regular.ttf") format("truetype"); 4 | font-weight: 400; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: "Montserrat"; 10 | src: url("./fonts/Montserrat-Bold.ttf") format("truetype"); 11 | font-weight: 700; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: "Montserrat"; 17 | src: url("./fonts/Montserrat-Black.ttf") format("truetype"); 18 | font-weight: 900; 19 | font-style: normal; 20 | } 21 | 22 | body { 23 | margin: 0; 24 | font-family: "Montserrat", -apple-system, BlinkMacSystemFont, "Segoe UI", 25 | "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", 26 | "Helvetica Neue", sans-serif; 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | } 30 | 31 | code { 32 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 33 | monospace; 34 | } 35 | 36 | a { 37 | color: #0148be; 38 | } 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./styles/index.css"; 4 | import "./index.css"; 5 | import App from "./App"; 6 | import RouterWrapper from "./RouterWrapper"; 7 | import * as serviceWorker from "./serviceWorker"; 8 | import "./fonts/Montserrat-Black.ttf"; 9 | import "./fonts/Montserrat-Regular.ttf"; 10 | 11 | import { Auth0Provider } from "./auth/react-auth0-wrapper"; 12 | import config from "./auth/auth-config.js"; 13 | import ApolloWrapper from "./ApolloWrapper"; 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | document.getElementById("root") 31 | ); 32 | 33 | // If you want your app to work offline and load faster, you can change 34 | // unregister() to register() below. Note this comes with some pitfalls. 35 | // Learn more about service workers: https://bit.ly/CRA-PWA 36 | serviceWorker.unregister(); 37 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/machines/appMachine.ts: -------------------------------------------------------------------------------- 1 | import Route from "route-parser"; 2 | import { Machine, assign, spawn, Interpreter } from "xstate"; 3 | import createPageMachine, { IContext as IPageContext } from "./page"; 4 | import { map } from "rxjs/operators"; 5 | import { todayDateString } from "../utils/datetime"; 6 | 7 | export const SELECT = "SELECT"; 8 | export const ROUTE_CHANGED = "ROUTE_CHANGED"; 9 | 10 | interface IPages { 11 | [page: string]: Interpreter | null; 12 | } 13 | 14 | interface IContext { 15 | page: Interpreter | null; 16 | pages: IPages; 17 | upsertLinks(links: any): any; 18 | upsertPage(page: any): any; 19 | getOrCreatePage(variables: any): any; 20 | deleteLinks(linkIds: any): any; 21 | getLinksByValue(value: any): any; 22 | getPagesByTitle(value: any): any; 23 | history$: any; 24 | } 25 | 26 | interface ISchema { 27 | states: { 28 | init: {}; 29 | selectedPage: {}; 30 | error: {}; 31 | }; 32 | } 33 | 34 | type IEvent = 35 | | { type: "SELECT"; pageTitle: string } 36 | | { type: "ROUTE_CHANGED"; value: any }; 37 | 38 | const resolveSelectedPageContext = ({ 39 | context, 40 | pageTitle: pageTitleFromUrl, 41 | }: { 42 | context: IContext; 43 | pageTitle: string; 44 | }) => { 45 | const pageTitle = pageTitleFromUrl || todayDateString(); 46 | let page = context.pages[pageTitle]; 47 | const { 48 | upsertLinks, 49 | upsertPage, 50 | deleteLinks, 51 | getOrCreatePage, 52 | getLinksByValue, 53 | getPagesByTitle, 54 | } = context; 55 | 56 | if (page) { 57 | return { 58 | ...context, 59 | page, 60 | }; 61 | } 62 | 63 | page = spawn( 64 | createPageMachine({ 65 | upsertLinks, 66 | upsertPage, 67 | deleteLinks, 68 | getOrCreatePage, 69 | getLinksByValue, 70 | getPagesByTitle, 71 | title: pageTitle, 72 | }) 73 | ); 74 | 75 | return { 76 | pages: { 77 | ...context.pages, 78 | [pageTitle]: page, 79 | }, 80 | page, 81 | }; 82 | }; 83 | 84 | const appMachine = Machine( 85 | { 86 | id: "app", 87 | initial: "init", 88 | invoke: { 89 | src: ({ history$ }) => { 90 | return history$.pipe( 91 | map(() => { 92 | return { 93 | type: ROUTE_CHANGED, 94 | }; 95 | }) 96 | ); 97 | }, 98 | }, 99 | context: { 100 | pages: {}, 101 | page: null, 102 | upsertLinks: () => null, 103 | upsertPage: () => null, 104 | deleteLinks: () => null, 105 | getOrCreatePage: () => null, 106 | getLinksByValue: () => null, 107 | getPagesByTitle: () => null, 108 | history$: null, 109 | }, 110 | states: { 111 | init: { 112 | on: { 113 | "": [ 114 | { 115 | target: "selectedPage", 116 | cond: { type: "verifyRoute", location: "/page/:pageTitle" }, 117 | }, 118 | { 119 | target: "selectedPage", 120 | cond: { type: "verifyRoute", location: "/" }, 121 | }, 122 | { target: "error" }, 123 | ], 124 | }, 125 | }, 126 | selectedPage: { 127 | entry: assign((context: any, event: any) => { 128 | const { pathname } = window.location; 129 | const route = new Route("/page/:pageTitle"); 130 | const { pageTitle } = route.match(pathname) as { pageTitle: string }; 131 | return resolveSelectedPageContext({ context, pageTitle }); 132 | }), 133 | }, 134 | error: {}, 135 | }, 136 | on: { 137 | [ROUTE_CHANGED]: [ 138 | { 139 | target: "selectedPage", 140 | cond: { type: "verifyRoute", location: "/page/:pageTitle" }, 141 | }, 142 | { 143 | target: "selectedPage", 144 | cond: { type: "verifyRoute", location: "/" }, 145 | }, 146 | { target: "error" }, 147 | ], 148 | [SELECT]: { 149 | target: ".selectedPage", 150 | actions: assign((context, event: any) => { 151 | const pageTitle = event.pageTitle; 152 | 153 | return resolveSelectedPageContext({ context, pageTitle }); 154 | }), 155 | }, 156 | }, 157 | }, 158 | { 159 | guards: { 160 | verifyRoute: (context: IContext, event: IEvent, { cond }: any) => { 161 | const { pathname } = window.location; 162 | const route = new Route(cond.location); 163 | if (route.match(pathname)) { 164 | return true; 165 | } 166 | return false; 167 | }, 168 | }, 169 | } 170 | ); 171 | 172 | export default appMachine; 173 | -------------------------------------------------------------------------------- /src/machines/page/editingLinkState.ts: -------------------------------------------------------------------------------- 1 | import { assign, sendParent } from "xstate"; 2 | import { IContext } from "."; 3 | import { CHANGE, SELECT_LINK, LINK_UPDATED } from "./events"; 4 | import { Node } from "slate"; 5 | import { placeholderNode } from "../../plugins/withLinks"; 6 | 7 | function invokeFetchPages({ getPagesByTitle, linkValueAtSelection }: IContext) { 8 | return getPagesByTitle({ 9 | title: `%${linkValueAtSelection}%`, 10 | }); 11 | } 12 | 13 | function setFilteredPages(_: IContext, event: any) { 14 | return event.data.data.page; 15 | } 16 | 17 | function getOrCreatePages({ 18 | editor, 19 | title, 20 | getOrCreatePage, 21 | touchedLinkNodes, 22 | }: IContext) { 23 | const touchedLinkIds = touchedLinkNodes.map((node) => node.id); 24 | const matchFn = (n: Node) => 25 | n.type === "link" && touchedLinkIds.includes(n.id); 26 | const serializedLinkEntries = editor.serializeLinks({ 27 | pageTitle: title, 28 | matchFn, 29 | }); 30 | 31 | if (!!serializedLinkEntries.length) { 32 | return Promise.all( 33 | serializedLinkEntries.map((linkEntry: any) => { 34 | return getOrCreatePage({ 35 | variables: { 36 | page: { title: linkEntry.value, node: placeholderNode }, 37 | }, 38 | }); 39 | }) 40 | ); 41 | } else return Promise.resolve(); 42 | } 43 | 44 | async function setLinkNodeValues({ editor }: IContext) { 45 | return new Promise((done) => 46 | setTimeout(() => { 47 | editor.setLinkNodeValues(); 48 | done(); 49 | }, 0) 50 | ); 51 | } 52 | 53 | const editingLinkState = { 54 | initial: "notEditing", 55 | states: { 56 | notEditing: { 57 | id: "notEditing", 58 | entry: [ 59 | assign({ 60 | touchedLinkNodes: [], 61 | }), 62 | ], 63 | on: { 64 | [CHANGE]: { 65 | target: "editing", 66 | cond: { type: "isEditingLinkNode" }, 67 | }, 68 | }, 69 | }, 70 | creatingNewPagesFromLinks: { 71 | id: "creatingNewPagesFromLinks", 72 | invoke: { 73 | src: getOrCreatePages, 74 | onDone: { 75 | target: "notEditing", 76 | }, 77 | }, 78 | }, 79 | settingLinkNodeValues: { 80 | id: "settingLinkNodeValues", 81 | invoke: { 82 | src: setLinkNodeValues, 83 | onDone: { 84 | target: "creatingNewPagesFromLinks", 85 | }, 86 | }, 87 | }, 88 | editing: { 89 | id: "editing", 90 | type: "parallel", 91 | states: { 92 | base: { 93 | entry: [ 94 | assign({ 95 | activeLinkId: ({ editor }: IContext) => editor.getActiveLinkId(), 96 | touchedLinkNodes: ({ editor }: IContext) => 97 | editor.touchedLinkNodes(), 98 | }), 99 | "positionTooltip", 100 | ], 101 | exit: [ 102 | assign({ 103 | activeLinkId: null, 104 | }), 105 | ], 106 | on: { 107 | [CHANGE]: [ 108 | { 109 | target: "#settingLinkNodeValues", 110 | cond: { type: "isNotEditingLinkNode" }, 111 | actions: ["removeBrokenLinkNodeEntries"], 112 | }, 113 | { 114 | target: "base", 115 | cond: { type: "isEditingNewLinkNode" }, 116 | actions: ["removeBrokenLinkNodeEntries"], 117 | }, 118 | ], 119 | [SELECT_LINK]: { 120 | actions: ["setSelectedLinkValue"], 121 | after: { 122 | 1: { target: "#upsertingLinks" }, 123 | }, 124 | }, 125 | [LINK_UPDATED]: { 126 | target: "#creatingNewPagesFromLinks", 127 | }, 128 | }, 129 | }, 130 | data: { 131 | initial: "loading", 132 | states: { 133 | idle: { 134 | on: { 135 | [CHANGE]: { 136 | target: "loading", 137 | actions: [ 138 | assign({ 139 | linkValueAtSelection: ({ editor }: IContext) => { 140 | if (editor.selection) { 141 | const node = Node.get( 142 | editor, 143 | editor.selection.anchor.path 144 | ); 145 | 146 | if (!node.text) return null; 147 | 148 | return editor.stripBrackets(node.text); 149 | } 150 | return ""; 151 | }, 152 | }), 153 | sendParent(CHANGE), 154 | ], 155 | }, 156 | }, 157 | }, 158 | loading: { 159 | invoke: { 160 | id: "fetch-links", 161 | src: invokeFetchPages, 162 | onDone: { 163 | target: "idle", 164 | actions: [ 165 | assign({ 166 | filteredPages: setFilteredPages, 167 | }), 168 | ], 169 | }, 170 | }, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | }, 177 | }; 178 | export default editingLinkState; 179 | -------------------------------------------------------------------------------- /src/machines/page/events.js: -------------------------------------------------------------------------------- 1 | export const CLOSE_BRACKET = "CLOSE_BRACKET"; 2 | export const KEY_DOWN = "KEY_DOWN"; 3 | export const CHANGE = "CHANGE"; 4 | export const SYNC = "SYNC"; 5 | export const BACKSPACE = "BACKSPACE"; 6 | export const UNINDENT_NODE = "UNINDENT_NODE"; 7 | export const SELECT_LINK = "SELECT_LINK"; 8 | export const INDENT_NODE = "INDENT_NODE"; 9 | export const INIT_LINK = "INIT_LINK"; 10 | export const LINK_CREATED = "LINK_CREATED"; 11 | export const LINK_UPDATED = "LINK_UPDATED"; 12 | export const INSERT_BREAK = "INSERT_BREAK"; 13 | export const INSERT_SOFT_BREAK = "INSERT_SOFT_BREAK"; 14 | export const SYNC_LIST_ITEM = "SYNC_LIST_ITEM"; 15 | export const TOGGLE_CODE_BLOCK = "TOGGLE_CODE_BLOCK"; 16 | -------------------------------------------------------------------------------- /src/machines/page/index.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign, actions, StateNodeConfig } from "xstate"; 2 | import { Node, createEditor, Editor, Range, NodeEntry } from "slate"; 3 | import { withHistory } from "slate-history"; 4 | import withLinks, { placeholderNode } from "../../plugins/withLinks"; 5 | import withHyperlinks from "../../plugins/withHyperlinks"; 6 | import withCodeBlockListItems from "../../plugins/withCodeBlockListItems"; 7 | import withSoftBreaks from "../../plugins/withSoftBreaks"; 8 | import withHelpers from "../../plugins/withHelpers"; 9 | import withList from "../../plugins/withList"; 10 | import { withReact, ReactEditor } from "slate-react"; 11 | import { createRef } from "react"; 12 | import pageSyncState from "./syncState"; 13 | import loadingState from "./loadingState"; 14 | import editingLinkState from "./editingLinkState"; 15 | import selectedListItemState from "./selectedListItemState"; 16 | import { arraysEqual } from "../../utils/array"; 17 | 18 | import { 19 | CLOSE_BRACKET, 20 | KEY_DOWN, 21 | CHANGE, 22 | BACKSPACE, 23 | UNINDENT_NODE, 24 | INDENT_NODE, 25 | INIT_LINK, 26 | LINK_CREATED, 27 | INSERT_BREAK, 28 | INSERT_SOFT_BREAK, 29 | TOGGLE_CODE_BLOCK, 30 | } from "./events"; 31 | 32 | const { send } = actions; 33 | 34 | export interface IContext { 35 | editor: Editor; 36 | upsertLinks(links: any): any; 37 | upsertPage(page: any): any; 38 | title: string; 39 | value: Node[] | any[]; 40 | deleteLinks(linkIds: any): any; 41 | getOrCreatePage(variables: any): Promise; 42 | getLinksByValue(value: any): Promise; 43 | getPagesByTitle(value: any): Promise; 44 | placeholderNode: Node; 45 | canBackspace: boolean; 46 | filteredPages: any[]; 47 | PagesTooltipRef: any; 48 | linkValueAtSelection: string; 49 | activeLinkId: string | null; 50 | errorMessage: string; 51 | touchedLinkNodes: Node[]; 52 | tags: any[]; 53 | prevSelectedListItem: NodeEntry | null; 54 | selectedListItem: NodeEntry | null; 55 | } 56 | 57 | export interface ISchema { 58 | states: { 59 | failed: {}; 60 | loading: {}; 61 | loaded: { 62 | states: { 63 | selectedListItem: {}; 64 | sync: { 65 | states: { 66 | unsynced: {}; 67 | synced: {}; 68 | failure: {}; 69 | syncingPage: {}; 70 | syncingLinks: {}; 71 | deletingLinks: {}; 72 | }; 73 | }; 74 | base: {}; 75 | editingLink: { 76 | states: { 77 | notEditing: {}; 78 | creatingNewPagesFromLinks: {}; 79 | settingLinkNodeValues: {}; 80 | syncing: {}; 81 | editing: { 82 | states: { 83 | base: {}; 84 | data: { 85 | states: { 86 | idle: {}; 87 | loading: {}; 88 | }; 89 | }; 90 | }; 91 | }; 92 | }; 93 | }; 94 | }; 95 | }; 96 | }; 97 | } 98 | 99 | export type IEvent = 100 | | { type: "KEY_DOWN"; key: string; shiftKey: boolean; metaKey: boolean } 101 | | { type: "CHANGE"; value: any } 102 | | { type: "INDENT_NODE" } 103 | | { type: "BACKSPACE" } 104 | | { type: "SYNC" } 105 | | { type: "INSERT_BREAK" } 106 | | { type: "INSERT_SOFT_BREAK" } 107 | | { type: "SELECT_LINK" } 108 | | { type: "LINK_UPDATED" } 109 | | { type: "SYNC_LIST_ITEM" } 110 | | { type: "SET_SELECTED_LIST_ITEM_NODE_LINK_CHILDREN" } 111 | | { type: "UNINDENT_NODE" } 112 | | { type: "INIT_LINK" } 113 | | { type: "LINK_CREATED" } 114 | | { type: "TOGGLE_CODE_BLOCK" } 115 | | { 116 | type: "CLOSE_BRACKET"; 117 | }; 118 | 119 | const getTriggerEvent = ( 120 | { editor }: IContext, 121 | { key, shiftKey, metaKey }: any 122 | ) => { 123 | switch (key) { 124 | case "Enter": 125 | if (shiftKey) { 126 | return { type: INSERT_SOFT_BREAK }; 127 | } else { 128 | return { type: INSERT_BREAK }; 129 | } 130 | case "Tab": 131 | if (shiftKey) { 132 | return { type: UNINDENT_NODE }; 133 | } else return { type: INDENT_NODE }; 134 | case "[": 135 | if (editor.willInitLink()) { 136 | return { type: INIT_LINK }; 137 | } else { 138 | return { type: CLOSE_BRACKET }; 139 | } 140 | case "/": 141 | if (metaKey) { 142 | return { type: TOGGLE_CODE_BLOCK }; 143 | } 144 | case "Backspace": 145 | return { type: BACKSPACE }; 146 | default: 147 | // TODO: better way to bail? 148 | return { type: "" }; 149 | } 150 | }; 151 | 152 | function canBackspace({ editor }: IContext) { 153 | return editor.canBackspace(); 154 | } 155 | 156 | const createPageMachine = ({ 157 | upsertLinks, 158 | upsertPage, 159 | deleteLinks, 160 | title, 161 | getOrCreatePage, 162 | getLinksByValue, 163 | getPagesByTitle, 164 | }: any) => 165 | Machine( 166 | { 167 | id: `page-${title}`, 168 | initial: "loading", 169 | context: { 170 | editor: withCodeBlockListItems( 171 | withSoftBreaks( 172 | withLinks( 173 | withHyperlinks( 174 | withHistory(withList(withHelpers(withReact(createEditor())))) 175 | ) 176 | ) 177 | ) 178 | ), 179 | upsertLinks, 180 | upsertPage, 181 | deleteLinks, 182 | title, 183 | getOrCreatePage, 184 | placeholderNode, 185 | getLinksByValue, 186 | getPagesByTitle, 187 | canBackspace: true, 188 | value: [], 189 | filteredPages: [], 190 | PagesTooltipRef: createRef(), 191 | linkValueAtSelection: "", 192 | activeLinkId: "", 193 | errorMessage: "", 194 | touchedLinkNodes: [], 195 | tags: [], 196 | prevSelectedListItem: null, 197 | selectedListItem: null, 198 | }, 199 | states: { 200 | failed: {}, 201 | loading: { 202 | ...loadingState, 203 | }, 204 | loaded: { 205 | type: "parallel", 206 | states: { 207 | sync: { 208 | ...(pageSyncState as StateNodeConfig), 209 | initial: "synced", 210 | }, 211 | selectedListItem: { 212 | ...(selectedListItemState as any), 213 | }, 214 | editingLink: { 215 | ...(editingLinkState as StateNodeConfig), 216 | initial: "notEditing", 217 | }, 218 | base: { 219 | on: { 220 | [CHANGE]: { 221 | actions: [ 222 | assign({ 223 | canBackspace, 224 | value: (_: IContext, { value }: any) => value, 225 | }), 226 | ], 227 | }, 228 | [BACKSPACE]: { 229 | actions: ["backspace"], 230 | }, 231 | [UNINDENT_NODE]: { 232 | actions: ["unindentNode"], 233 | }, 234 | [TOGGLE_CODE_BLOCK]: { 235 | actions: ["toggleCodeBlock"], 236 | }, 237 | [INDENT_NODE]: { 238 | actions: ["indentNode"], 239 | }, 240 | [INSERT_BREAK]: { 241 | actions: ["insertBreak"], 242 | }, 243 | [INSERT_SOFT_BREAK]: { 244 | actions: ["insertSoftBreak"], 245 | }, 246 | [KEY_DOWN]: { 247 | actions: [send(getTriggerEvent)], 248 | }, 249 | [CLOSE_BRACKET]: { 250 | actions: ["closeBracket"], 251 | }, 252 | [INIT_LINK]: { 253 | actions: ["closeBracket", "initLink", send(LINK_CREATED)], 254 | }, 255 | }, 256 | }, 257 | }, 258 | }, 259 | }, 260 | }, 261 | { 262 | guards: { 263 | isEditingLinkNode: ({ editor }: IContext) => { 264 | return !!editor.touchedLinkNodes().length; 265 | }, 266 | isNotEditingLinkNode: ({ editor }: IContext) => { 267 | return !editor.touchedLinkNodes().length; 268 | }, 269 | isEditingNewLinkNode: ({ editor, activeLinkId }: IContext) => { 270 | const parent = editor.getParentNodeAtSelection(); 271 | return parent.id !== activeLinkId; 272 | }, 273 | isSelectionAtNewListItem: ({ 274 | prevSelectedListItem, 275 | selectedListItem, 276 | }: IContext) => { 277 | if (!selectedListItem || !prevSelectedListItem) return true; 278 | return !arraysEqual(prevSelectedListItem[1], selectedListItem[1]); 279 | }, 280 | }, 281 | actions: { 282 | positionTooltip: ({ PagesTooltipRef, editor }: IContext) => { 283 | const { selection } = editor; 284 | if (selection && Range.isCollapsed(selection)) { 285 | setTimeout(() => { 286 | try { 287 | const [start] = Range.edges(selection); 288 | const wordBefore = 289 | start && Editor.before(editor, start, { unit: "word" }); 290 | const before = wordBefore && Editor.before(editor, wordBefore); 291 | let beforeRange = before && Editor.range(editor, before, start); 292 | 293 | const domRange = ReactEditor.toDOMRange( 294 | editor as ReactEditor, 295 | beforeRange || (editor.selection as Range) 296 | ); 297 | const rect = domRange.getBoundingClientRect(); 298 | if (PagesTooltipRef?.current) { 299 | PagesTooltipRef.current.style.top = `${ 300 | rect.top + window.pageYOffset + 24 301 | }px`; 302 | PagesTooltipRef.current.style.left = `${ 303 | rect.left + window.pageXOffset 304 | }px`; 305 | } 306 | } catch (e) { 307 | if (PagesTooltipRef?.current) { 308 | PagesTooltipRef.current.style.display = "hidden"; 309 | } 310 | } 311 | }, 0); 312 | } 313 | }, 314 | backspace: ({ editor, canBackspace }: IContext) => { 315 | if (canBackspace) { 316 | editor.handleBackSpace(); 317 | } 318 | }, 319 | unindentNode: ({ editor }: IContext) => { 320 | editor.unindentNode(); 321 | }, 322 | indentNode: ({ editor }: IContext) => { 323 | editor.indentNode(); 324 | }, 325 | insertBreak: ({ editor }: IContext) => { 326 | editor.insertBreak(); 327 | }, 328 | insertSoftBreak: ({ editor }: IContext) => { 329 | editor.insertSoftBreak(); 330 | }, 331 | closeBracket: ({ editor }: IContext) => { 332 | editor.closeBracket(); 333 | }, 334 | toggleCodeBlock: ({ editor }: IContext) => { 335 | editor.toggleCodeBlock(); 336 | }, 337 | initLink: ({ editor }: IContext) => { 338 | editor.initLink(); 339 | }, 340 | setSelectedLinkValue: ({ editor }: IContext, event: any) => { 341 | editor.setLinkValue({ value: event.node.title }); 342 | }, 343 | removeBrokenLinkNodeEntries: ({ 344 | editor, 345 | touchedLinkNodes, 346 | }: IContext) => { 347 | setTimeout(() => { 348 | editor.removeBrokenLinkNodeEntries({ touchedLinkNodes }); 349 | }, 0); 350 | }, 351 | setLinkNodeValues: ({ editor }: IContext) => { 352 | setTimeout(() => { 353 | editor.setLinkNodeValues(); 354 | }, 0); 355 | }, 356 | wrapHyperlinks: ({ editor, prevSelectedListItem }: IContext) => { 357 | if (!prevSelectedListItem) return; 358 | setTimeout(() => { 359 | editor.wrapHyperlinks(prevSelectedListItem); 360 | }, 0); 361 | }, 362 | unwrapHyperlinks: ({ editor, selectedListItem }: IContext) => { 363 | if (!selectedListItem) return; 364 | setTimeout(() => { 365 | editor.unwrapHyperlinks(selectedListItem); 366 | }, 0); 367 | }, 368 | }, 369 | } 370 | ); 371 | 372 | export default createPageMachine; 373 | -------------------------------------------------------------------------------- /src/machines/page/loadingState.ts: -------------------------------------------------------------------------------- 1 | import { assign } from "xstate"; 2 | import { IContext } from "./index"; 3 | import { placeholderNode } from "../../plugins/withLinks"; 4 | 5 | function invokeFetchPage({ title, getOrCreatePage }: IContext) { 6 | return getOrCreatePage({ 7 | variables: { 8 | page: { title, node: placeholderNode, isDaily: true }, 9 | }, 10 | }); 11 | } 12 | 13 | function setValue(_: IContext, event: any) { 14 | return [event.data.data.insert_page.returning[0].node]; 15 | } 16 | 17 | function setTitle(_: IContext, event: any) { 18 | return event.data.data.insert_page.returning[0].title; 19 | } 20 | 21 | const loadingState = { 22 | invoke: { 23 | id: "fetch-page", 24 | src: invokeFetchPage, 25 | onDone: { 26 | target: "loaded", 27 | actions: [ 28 | assign({ 29 | value: setValue, 30 | title: setTitle, 31 | }), 32 | ], 33 | }, 34 | onError: "failed", 35 | }, 36 | }; 37 | 38 | export default loadingState; 39 | -------------------------------------------------------------------------------- /src/machines/page/selectedListItemState.ts: -------------------------------------------------------------------------------- 1 | import { assign, actions } from "xstate"; 2 | import { CHANGE } from "./events"; 3 | const { send } = actions; 4 | const UPDATE = "update"; 5 | 6 | const selectedListItemState = { 7 | on: { 8 | [CHANGE]: { 9 | actions: [ 10 | assign({ 11 | prevSelectedListItem: ({ selectedListItem }) => selectedListItem, 12 | selectedListItem: ({ editor }) => 13 | editor.parentListItemEntryAtSelection() && 14 | editor.parentListItemEntryAtSelection()[0], 15 | }), 16 | send(UPDATE), 17 | ], 18 | }, 19 | [UPDATE]: { 20 | actions: ["unwrapHyperlinks", "wrapHyperlinks"], 21 | cond: { type: "isSelectionAtNewListItem" }, 22 | }, 23 | }, 24 | }; 25 | export default selectedListItemState; 26 | -------------------------------------------------------------------------------- /src/machines/page/syncState.ts: -------------------------------------------------------------------------------- 1 | import { actions, assign } from "xstate"; 2 | import { IContext } from "."; 3 | import { ApolloCurrentQueryResult } from "apollo-boost"; 4 | import { NodeEntry } from "slate"; 5 | const { send, cancel } = actions; 6 | 7 | const SYNC = "SYNC"; 8 | const CHANGE = "CHANGE"; 9 | 10 | function upsertLinks({ upsertLinks, editor, title }: IContext) { 11 | const serializedLinkEntries = editor.serializeLinks({ 12 | pageTitle: title, 13 | }); 14 | 15 | if (!!serializedLinkEntries.length) { 16 | return upsertLinks({ 17 | variables: { links: serializedLinkEntries }, 18 | }); 19 | } else return Promise.resolve(); 20 | } 21 | 22 | const pageSyncState = { 23 | initial: "synced" as string, 24 | on: { 25 | [CHANGE]: { 26 | target: ".unsynced", 27 | actions: [ 28 | cancel("syncTimeout"), 29 | send(SYNC, { 30 | delay: 2000, 31 | id: "syncTimeout", 32 | }), 33 | ], 34 | }, 35 | [SYNC]: { 36 | target: ".syncingPage", 37 | }, 38 | }, 39 | states: { 40 | unsynced: {}, 41 | synced: {}, 42 | failure: {}, 43 | syncingLinks: { 44 | id: "upsertingLinks", 45 | invoke: { 46 | src: upsertLinks, 47 | onDone: { target: "synced" }, 48 | onError: { 49 | target: "failure", 50 | actions: assign({ 51 | errorMessage: (_, event: ApolloCurrentQueryResult) => { 52 | return event.data.toString(); 53 | }, 54 | }), 55 | }, 56 | }, 57 | }, 58 | deletingLinks: { 59 | invoke: { 60 | src: ({ editor, tags: persistedTags, deleteLinks }: IContext) => { 61 | const tags = editor.getLinkNodeEntries(); 62 | const tagIds = tags.map(([node]: NodeEntry) => node.id); 63 | const tagIdsToDestroy = persistedTags 64 | .map(({ id }) => id) 65 | .filter((id) => !tagIds.includes(id)); 66 | return deleteLinks({ 67 | variables: { linkIds: tagIdsToDestroy }, 68 | }); 69 | }, 70 | onDone: { 71 | target: "syncingLinks", 72 | }, 73 | }, 74 | }, 75 | syncingPage: { 76 | invoke: { 77 | src: ({ upsertPage, title, value }: IContext) => { 78 | return upsertPage({ 79 | variables: { page: { node: value[0], title } }, 80 | }); 81 | }, 82 | onDone: { 83 | target: "deletingLinks", 84 | actions: assign({ 85 | tags: (_, event: ApolloCurrentQueryResult) => { 86 | return event.data.data.insert_page.returning[0].tags; 87 | }, 88 | }), 89 | }, 90 | onError: { 91 | target: "failure", 92 | actions: assign({ 93 | errorMessage: (_, event: ApolloCurrentQueryResult) => { 94 | return event.data.toString(); 95 | }, 96 | }), 97 | }, 98 | }, 99 | }, 100 | }, 101 | }; 102 | 103 | export default pageSyncState; 104 | -------------------------------------------------------------------------------- /src/mutations/deleteLinks.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const DELETE_LINKS = gql` 4 | mutation DeleteLinks($linkIds: [uuid!]!) { 5 | delete_link(where: { id: { _in: $linkIds } }) { 6 | returning { 7 | id 8 | } 9 | } 10 | } 11 | `; 12 | 13 | export default DELETE_LINKS; 14 | -------------------------------------------------------------------------------- /src/mutations/getOrCreatePage.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const GET_OR_CREATE_PAGE = gql` 4 | mutation GetOrCreatePage($page: [page_insert_input!]!) { 5 | insert_page( 6 | objects: $page 7 | on_conflict: { constraint: page_pkey, update_columns: [title] } 8 | ) { 9 | returning { 10 | title 11 | node 12 | references { 13 | id 14 | } 15 | tags { 16 | id 17 | } 18 | } 19 | } 20 | } 21 | `; 22 | 23 | export default GET_OR_CREATE_PAGE; 24 | -------------------------------------------------------------------------------- /src/mutations/upsertLinks.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const UPSERT_LINKS = gql` 4 | mutation UpsertLinks($links: [link_insert_input!]!) { 5 | insert_link( 6 | objects: $links 7 | on_conflict: { 8 | constraint: link_pkey 9 | update_columns: [value, listItemNode, pageTitle] 10 | } 11 | ) { 12 | returning { 13 | id 14 | } 15 | } 16 | } 17 | `; 18 | 19 | export default UPSERT_LINKS; 20 | -------------------------------------------------------------------------------- /src/mutations/upsertPage.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const UPSERT_PAGE = gql` 4 | mutation UpsertPage($page: [page_insert_input!]!) { 5 | insert_page( 6 | objects: $page 7 | on_conflict: { constraint: page_pkey, update_columns: [node] } 8 | ) { 9 | returning { 10 | title 11 | tags { 12 | id 13 | } 14 | } 15 | } 16 | } 17 | `; 18 | 19 | export default UPSERT_PAGE; 20 | -------------------------------------------------------------------------------- /src/plugins/withCodeBlockListItems.ts: -------------------------------------------------------------------------------- 1 | import { Transforms, Editor } from "slate"; 2 | 3 | const withCodeBlockListItems = (editor: any) => { 4 | const { insertData } = editor; 5 | 6 | editor.insertData = (data: any) => { 7 | insertData(data); 8 | }; 9 | editor.toggleCodeBlock = () => { 10 | const [match] = Editor.nodes(editor, { 11 | match: (n) => n.type === "codeBlock", 12 | }); 13 | Transforms.setNodes( 14 | editor, 15 | { type: match ? "text-wrapper" : "codeBlock" }, 16 | { match: (n) => Editor.isBlock(editor, n) } 17 | ); 18 | }; 19 | return editor; 20 | }; 21 | 22 | export default withCodeBlockListItems; 23 | -------------------------------------------------------------------------------- /src/plugins/withHelpers.ts: -------------------------------------------------------------------------------- 1 | import { Node, NodeEntry, Path } from "slate"; 2 | 3 | export function getPreviousSiblingPath(path: any) { 4 | return [...path.slice(0, path.length - 1), path[path.length - 1] - 1]; 5 | } 6 | 7 | export function nextSiblingPath(path: any) { 8 | return [...path.slice(0, path.length - 1), path[path.length - 1] + 1]; 9 | } 10 | 11 | export function isFirstChild([, path]: NodeEntry) { 12 | return !path[path.length - 1]; 13 | } 14 | 15 | const withHelpers = (editor: any) => { 16 | editor.getParentNodeAtSelection = () => { 17 | if (!editor.selection) return null; 18 | const { path } = editor.selection.anchor; 19 | const parentNode = Node.get(editor, path.slice(0, path.length - 1)); 20 | return parentNode; 21 | }; 22 | 23 | editor.getParentNode = (path: Path) => { 24 | if (!path) return null; 25 | const parentNode = Node.get(editor, path.slice(0, path.length - 1)); 26 | return parentNode; 27 | }; 28 | 29 | return editor; 30 | }; 31 | 32 | export default withHelpers; 33 | -------------------------------------------------------------------------------- /src/plugins/withHyperlinks.ts: -------------------------------------------------------------------------------- 1 | import { Editor, NodeEntry, Node, Transforms } from "slate"; 2 | 3 | const hyperlinkMatch = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/g; 4 | 5 | // TODO: this can be simplified with something like this: https://github.com/ianstormtaylor/slate/blob/master/site/examples/links.js 6 | 7 | const withHyperlinks = (editor: Editor) => { 8 | const { isInline } = editor; 9 | 10 | editor.isInline = (element: any) => { 11 | return element.type === "hyperlink" ? true : isInline(editor); 12 | }; 13 | 14 | editor.unwrapHyperlinks = ([, parentPath]: NodeEntry) => { 15 | try { 16 | for (const [, path] of Editor.nodes(editor, { 17 | at: parentPath, 18 | match: (n) => n.type === "hyperlink", 19 | })) { 20 | if (path.length - parentPath.length > 2) { 21 | return; 22 | } 23 | Transforms.unwrapNodes(editor, { 24 | at: path, 25 | mode: "highest", 26 | }); 27 | } 28 | } catch (e) { 29 | console.log("nothing to unwrap"); 30 | } 31 | }; 32 | 33 | editor.wrapHyperlinks = (nodeEntry: NodeEntry) => { 34 | const [, parentPath] = nodeEntry; 35 | 36 | try { 37 | for (const [node, path] of Node.texts(editor, { 38 | from: parentPath, 39 | to: parentPath, 40 | })) { 41 | if (path.length - parentPath.length > 2) { 42 | return; 43 | } 44 | let match; 45 | let str = node.text; 46 | 47 | let matches = []; 48 | while ((match = hyperlinkMatch.exec(str)) !== null) { 49 | matches.push(match); 50 | } 51 | matches.reverse().forEach((match) => { 52 | Transforms.wrapNodes( 53 | editor, 54 | { 55 | type: "hyperlink", 56 | url: match[0], 57 | children: [], 58 | }, 59 | { 60 | mode: "lowest", 61 | at: { 62 | anchor: { path, offset: match.index }, 63 | focus: { 64 | path, 65 | offset: match.index + match[0].length, 66 | }, 67 | }, 68 | split: true, 69 | } 70 | ); 71 | }); 72 | } 73 | } catch (e) { 74 | console.log("no hyperlinks to wrap"); 75 | } 76 | }; 77 | 78 | return editor; 79 | }; 80 | 81 | export default withHyperlinks; 82 | -------------------------------------------------------------------------------- /src/plugins/withLinks.ts: -------------------------------------------------------------------------------- 1 | import { Transforms, Editor, Node, NodeEntry, Text } from "slate"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | export const placeholderNode = { 5 | type: "list", 6 | children: [ 7 | { 8 | type: "list-item", 9 | children: [ 10 | { 11 | type: "text-wrapper", 12 | children: [ 13 | { 14 | text: "Enter some text...", 15 | }, 16 | ], 17 | }, 18 | ], 19 | }, 20 | ], 21 | }; 22 | 23 | const withLink = (editor: any) => { 24 | const { isInline } = editor; 25 | 26 | editor.isInline = (element: any) => { 27 | return element.type === "link" ? true : isInline(editor); 28 | }; 29 | 30 | editor.closeBracket = () => { 31 | Transforms.insertText(editor, "]"); 32 | Transforms.move(editor, { distance: 1, reverse: true }); 33 | }; 34 | 35 | editor.isLinkNodeEntryBroken = ([node]: NodeEntry) => { 36 | const linkText = node.children[0].text; 37 | return ( 38 | !(linkText.slice(0, 2) === "[[") || 39 | !(linkText.slice(linkText.length - 2, linkText.length) === "]]") 40 | ); 41 | }; 42 | 43 | editor.getLinkValueFromNodeEntry = ([node]: NodeEntry) => { 44 | const linkText = node.children[0].text; 45 | return linkText.substring(2, linkText.length - 2); 46 | }; 47 | 48 | editor.getLinkNodesToDestroy = () => { 49 | return editor 50 | .getLinkNodeEntries() 51 | .filter((nodeEntry: NodeEntry) => 52 | editor.isLinkNodeEntryBroken(nodeEntry) 53 | ); 54 | }; 55 | 56 | editor.getLinkNodeEntries = ({ matchFn }: any = {}) => { 57 | const defaultMatch = (n: Node) => n.type === "link"; 58 | const match = matchFn || defaultMatch; 59 | return Array.from( 60 | Editor.nodes(editor, { 61 | at: [0], 62 | match, 63 | }) 64 | ); 65 | }; 66 | 67 | editor.removeBrokenLinkNodeEntries = () => { 68 | const linkNodeEntriesToDestroy = editor.getLinkNodesToDestroy(); 69 | 70 | // TODO: this can prob be .unwrapNodes 71 | linkNodeEntriesToDestroy.forEach(([node, path]: NodeEntry) => { 72 | Transforms.insertNodes(editor, node.children[0], { 73 | at: path, 74 | }); 75 | 76 | Transforms.removeNodes(editor, { 77 | at: path, 78 | }); 79 | }); 80 | }; 81 | 82 | editor.initLink = () => { 83 | editor.deleteForward({ unit: "character" }); 84 | editor.deleteBackward({ unit: "character" }); 85 | editor.deleteForward({ unit: "character" }); 86 | 87 | Transforms.insertNodes(editor, { 88 | type: "link", 89 | isInline: true, 90 | isIncomplete: true, 91 | id: uuid(), 92 | value: "", 93 | children: [{ text: "[]]" }], 94 | }); 95 | Transforms.move(editor, { distance: 2, unit: "character", reverse: true }); 96 | }; 97 | 98 | editor.setLinkNodeValues = () => { 99 | const linkEntries = editor.getLinkNodeEntries(); 100 | 101 | linkEntries.forEach((nodeEntry: NodeEntry) => { 102 | const value = editor.getLinkValueFromNodeEntry(nodeEntry); 103 | Transforms.setNodes( 104 | editor, 105 | // links need to have at least one character 106 | { value, isIncomplete: !value.trim().length }, 107 | { 108 | at: nodeEntry[1], 109 | match: ({ type }: Node) => type === "link", 110 | } 111 | ); 112 | }); 113 | }; 114 | 115 | editor.getLinkValueFromNode = (node: Node) => { 116 | const linkWithBrackets = node.children[0].text; 117 | return linkWithBrackets.substring(2, linkWithBrackets.length - 2); 118 | }; 119 | 120 | editor.stripBrackets = (text: string) => { 121 | return text.substring(2, text.length - 2); 122 | }; 123 | 124 | editor.serializeLinkEntry = ({ 125 | linkEntry, 126 | pageTitle, 127 | }: { 128 | linkEntry: NodeEntry; 129 | pageTitle: string; 130 | }) => { 131 | const [node, path] = linkEntry; 132 | return { 133 | id: node.id, 134 | value: node.value, 135 | pageTitle, 136 | listItemNode: editor.parentRootListItemFromPath(path)[0], 137 | }; 138 | }; 139 | 140 | editor.serializeLinks = ({ 141 | pageTitle, 142 | matchFn, 143 | }: { 144 | linkEntries: NodeEntry[]; 145 | previousLinkEntries: NodeEntry[]; 146 | pageTitle: string; 147 | matchFn: any; 148 | }) => { 149 | const serializedLinkEntries = editor 150 | .getLinkNodeEntries({ matchFn }) 151 | .map((linkEntry: NodeEntry) => 152 | editor.serializeLinkEntry({ linkEntry, pageTitle }) 153 | ) 154 | .filter((linkNode: Node) => { 155 | return !linkNode.isIncomplete; 156 | }); 157 | return serializedLinkEntries; 158 | }; 159 | 160 | editor.willInitLink = () => { 161 | const selection = editor.selection; 162 | 163 | // if selection, won't init link 164 | if (selection.anchor.offset !== selection.focus.offset) return false; 165 | 166 | const [node] = Array.from( 167 | Editor.nodes(editor, { 168 | at: selection.focus, 169 | match: Text.isText, 170 | }) 171 | )[0]; 172 | 173 | const prevCharacter = node.text[selection.focus.offset - 1]; 174 | return prevCharacter === "["; 175 | }; 176 | 177 | editor.handleBackSpace = () => { 178 | const selection = editor.selection; 179 | 180 | // if selection, nothing to do 181 | if (selection.anchor.offset !== selection.focus.offset) return; 182 | 183 | const [node] = Array.from( 184 | Editor.nodes(editor, { 185 | at: selection.focus, 186 | match: Text.isText, 187 | }) 188 | )[0]; 189 | 190 | const charToDelete = node.text[selection.focus.offset - 1]; 191 | const charAfterCursor = node.text[selection.focus.offset]; 192 | if (charToDelete === "[" && charAfterCursor === "]") { 193 | editor.deleteForward({ unit: "character" }); 194 | } 195 | }; 196 | 197 | editor.getActiveLinkId = () => { 198 | const parentNodeAtSelection = editor.getParentNodeAtSelection(); 199 | if (!parentNodeAtSelection || parentNodeAtSelection.type !== "link") { 200 | return null; 201 | } 202 | return parentNodeAtSelection.id; 203 | }; 204 | 205 | editor.setLinkValue = ({ value }: any) => { 206 | const { selection } = editor; 207 | const ancestors = Array.from( 208 | Node.ancestors(editor, selection.anchor.path, { reverse: true }) 209 | ); 210 | const [node, path] = ancestors.find( 211 | (ancestor) => ancestor[0].type === "link" 212 | ) as NodeEntry; 213 | 214 | Transforms.removeNodes(editor, { 215 | match: ({ type }) => type === "link", 216 | }); 217 | Transforms.insertNodes( 218 | editor, 219 | { 220 | ...node, 221 | children: [{ text: `[[${value}]]` }], 222 | }, 223 | { 224 | at: path, 225 | } 226 | ); 227 | }; 228 | 229 | editor.touchedLinkNodes = () => { 230 | const { selection } = editor; 231 | if (!selection) return false; 232 | 233 | const before = Editor.before(editor, selection.anchor); 234 | const beforeParentNode = before && editor.getParentNode(before.path); 235 | const parentNode = editor.getParentNodeAtSelection(); 236 | 237 | return [beforeParentNode, parentNode].filter( 238 | (node) => !!node && node.type === "link" 239 | ); 240 | }; 241 | 242 | return editor; 243 | }; 244 | 245 | export default withLink; 246 | -------------------------------------------------------------------------------- /src/plugins/withList.ts: -------------------------------------------------------------------------------- 1 | import { Node, Editor, Transforms } from "slate"; 2 | import { 3 | nextSiblingPath, 4 | isFirstChild, 5 | getPreviousSiblingPath, 6 | } from "./withHelpers"; 7 | 8 | const withList = (editor: any) => { 9 | editor.parentListItemFromPath = (path: any) => { 10 | if (!path) return; 11 | 12 | try { 13 | const ancestors = Array.from( 14 | Node.ancestors(editor, path, { reverse: true }) 15 | ); 16 | 17 | return ancestors.find((ancestor) => ancestor[0].type === "list-item"); 18 | } catch (e) { 19 | console.log(`Couldn't find parent list item entry`); 20 | return null; 21 | } 22 | }; 23 | 24 | editor.parentListItemEntryAtSelection = () => { 25 | if (!editor.selection) return null; 26 | const { selection } = editor; 27 | const parentListItemEntry = Array.from( 28 | Editor.nodes(editor, { 29 | at: selection, 30 | match: (n) => n.type === "list-item", 31 | mode: "lowest", 32 | }) 33 | ); 34 | 35 | return parentListItemEntry; 36 | }; 37 | 38 | editor.parentRootListItemFromPath = (path: any) => { 39 | if (!path) return; 40 | 41 | try { 42 | const ancestors = Array.from(Node.ancestors(editor, path)); 43 | 44 | return ancestors.find((ancestor) => ancestor[0].type === "list-item"); 45 | } catch (e) { 46 | console.log(`Couldn't find parent list item entry`); 47 | return null; 48 | } 49 | }; 50 | 51 | editor.childListItemEntriesFromPath = (path: any) => { 52 | if (!path) return; 53 | 54 | const children = Array.from(Node.children(editor, path)).filter( 55 | ([n]) => n.type === "list" 56 | ); 57 | 58 | return children; 59 | }; 60 | 61 | editor.grandParentListFromSelection = () => { 62 | const { path } = editor.selection.anchor; 63 | const ancestors = Array.from( 64 | Node.ancestors(editor, path, { reverse: true }) 65 | ); 66 | 67 | return ancestors.find((ancestor) => ancestor[0].type === "list"); 68 | }; 69 | 70 | editor.greatGrandParentListItemFromSelection = () => { 71 | const { path } = editor.selection.anchor; 72 | const ancestors = Array.from( 73 | Node.ancestors(editor, path, { reverse: true }) 74 | ); 75 | 76 | return ancestors.filter(([{ type }]) => type === "list-item")[1]; 77 | }; 78 | 79 | editor.insertBreak = () => { 80 | const { path } = editor.selection.anchor; 81 | const [, parentListItemPath] = editor.parentListItemFromPath(path); 82 | 83 | const elementListTuple = Array.from( 84 | Node.children(editor, parentListItemPath) 85 | ).find(([element]) => element.type === "list"); 86 | 87 | // insert a child if children 88 | if (!!elementListTuple) { 89 | const [, elementListPath] = elementListTuple; 90 | const destination = elementListPath.concat(0); 91 | Transforms.insertNodes( 92 | editor, 93 | { 94 | type: "list-item", 95 | children: [{ type: "text-wrapper", children: [] }], 96 | }, 97 | { at: destination } 98 | ); 99 | 100 | Transforms.setSelection(editor, { 101 | anchor: { path: destination, offset: 0 }, 102 | focus: { path: destination, offset: 0 }, 103 | }); 104 | } else { 105 | // insert a sibling if no children 106 | const destination = nextSiblingPath(parentListItemPath); 107 | Transforms.insertNodes( 108 | editor, 109 | { 110 | type: "list-item", 111 | children: [{ type: "text-wrapper", children: [] }], 112 | }, 113 | { at: destination } 114 | ); 115 | 116 | Transforms.setSelection(editor, { 117 | anchor: { path: destination, offset: 0 }, 118 | focus: { path: destination, offset: 0 }, 119 | }); 120 | } 121 | }; 122 | 123 | editor.indentNode = () => { 124 | const { path } = editor.selection.anchor; 125 | 126 | const parentListItemNodeEntry = editor.parentListItemFromPath(path); 127 | 128 | if (!parentListItemNodeEntry || isFirstChild(parentListItemNodeEntry)) { 129 | return; 130 | } 131 | const [, parentListItemPath] = parentListItemNodeEntry; 132 | 133 | const parentListItemsPreviousSibling = Node.get( 134 | editor, 135 | getPreviousSiblingPath(parentListItemPath) 136 | ); 137 | 138 | const previousSiblingPath = getPreviousSiblingPath(parentListItemPath); 139 | const hasListChild = !!parentListItemsPreviousSibling.children.find( 140 | (child: Node) => child.type === "list" 141 | ); 142 | 143 | // If it's just a text-wrapper (no list) 144 | if (!hasListChild) { 145 | const targetListNodePath = previousSiblingPath.concat( 146 | parentListItemsPreviousSibling.children.length 147 | ); 148 | Transforms.insertNodes( 149 | editor, 150 | { 151 | type: "list", 152 | children: [], 153 | }, 154 | { at: targetListNodePath } 155 | ); 156 | 157 | Transforms.moveNodes(editor, { 158 | to: targetListNodePath.concat(0), 159 | at: parentListItemPath, 160 | }); 161 | } else { 162 | const previousSiblingExistingListPath = previousSiblingPath.concat( 163 | parentListItemsPreviousSibling.children.length - 1 164 | ); 165 | const targetListNode = Node.get(editor, previousSiblingExistingListPath); 166 | Transforms.moveNodes(editor, { 167 | to: previousSiblingExistingListPath.concat( 168 | targetListNode.children.length 169 | ), 170 | at: parentListItemPath, 171 | }); 172 | } 173 | }; 174 | 175 | editor.moveSubsequentListItemSiblingsIntoGrandParentList = () => { 176 | const { path } = editor.selection.anchor; 177 | const [parentListItem, parentListItemPath] = editor.parentListItemFromPath( 178 | path 179 | ); 180 | const [grandparentList] = editor.grandParentListFromSelection(); 181 | const parentLineItemPositionInList = grandparentList.children.indexOf( 182 | parentListItem 183 | ); 184 | 185 | const targetListPath = parentListItemPath.concat( 186 | parentListItem.children.length 187 | ); 188 | 189 | const parentListItemChildList = parentListItem.children.find( 190 | (child: Node) => child.type === "list" 191 | ); 192 | const hasListChild = !!parentListItemChildList; 193 | 194 | // If the list-item isn't the last in it's list and therefore has siblings to move 195 | if (grandparentList.children.length - 1 > parentLineItemPositionInList) { 196 | // If the list-item doesn't already have a list beneath it, create one 197 | if (!hasListChild) { 198 | Transforms.insertNodes( 199 | editor, 200 | { type: "list", children: [] }, 201 | { at: targetListPath } 202 | ); 203 | } 204 | 205 | const targetListExistingItemCount = 206 | parentListItemChildList?.children.length || 0; 207 | 208 | let i; 209 | for ( 210 | i = parentLineItemPositionInList + 1; 211 | i < grandparentList.children.length; 212 | i++ 213 | ) { 214 | let originPath = nextSiblingPath(parentListItemPath); 215 | 216 | Transforms.moveNodes(editor, { 217 | at: originPath, 218 | to: targetListPath.concat( 219 | i - parentLineItemPositionInList - 1 + targetListExistingItemCount 220 | ), 221 | }); 222 | } 223 | } 224 | }; 225 | 226 | editor.unindentNode = () => { 227 | // Bail if grandParentList is root 228 | if (editor.grandParentListFromSelection()[1].length === 1) { 229 | return; 230 | } 231 | 232 | editor.moveSubsequentListItemSiblingsIntoGrandParentList(); 233 | 234 | const { path } = editor.selection.anchor; 235 | const [, parentListItemPath] = editor.parentListItemFromPath(path); 236 | const [, grandParentListPath] = editor.grandParentListFromSelection(); 237 | const [ 238 | , 239 | greatGrandParentListItemPath, 240 | ] = editor.greatGrandParentListItemFromSelection(); 241 | 242 | Transforms.moveNodes(editor, { 243 | at: parentListItemPath, 244 | to: nextSiblingPath(greatGrandParentListItemPath), 245 | }); 246 | 247 | const previousGrandParentList = Node.get(editor, grandParentListPath); 248 | if (!previousGrandParentList.children[0].type) { 249 | Transforms.removeNodes(editor, { at: grandParentListPath }); 250 | } 251 | }; 252 | 253 | editor.canBackspace = () => { 254 | const selection = editor.selection; 255 | if (!selection) return; 256 | const parentListItemFromPath = editor.parentListItemFromPath( 257 | selection.anchor.path 258 | ); 259 | if (!parentListItemFromPath) return true; 260 | const [, path] = parentListItemFromPath; 261 | 262 | if (selection.anchor.offset === 0 && selection.focus.offset === 0) { 263 | const listItemChildren = editor.childListItemEntriesFromPath(path); 264 | const hasListItemChildren = !!listItemChildren.length; 265 | 266 | return !hasListItemChildren; 267 | } 268 | 269 | return true; 270 | }; 271 | 272 | return editor; 273 | }; 274 | 275 | export default withList; 276 | -------------------------------------------------------------------------------- /src/plugins/withSoftBreaks.ts: -------------------------------------------------------------------------------- 1 | import { Transforms } from "slate"; 2 | 3 | const withSoftBreaks = (editor: any) => { 4 | editor.insertSoftBreak = () => { 5 | Transforms.insertText(editor, "\n"); 6 | }; 7 | return editor; 8 | }; 9 | 10 | export default withSoftBreaks; 11 | -------------------------------------------------------------------------------- /src/queries/getLinksByValue.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const GET_LINKS_BY_VALUE = gql` 4 | query GetLinksByValue($value: String) { 5 | link(where: { value: { _ilike: $value } }) { 6 | id 7 | listItemNode 8 | pageTitle 9 | value 10 | } 11 | } 12 | `; 13 | 14 | export default GET_LINKS_BY_VALUE; 15 | -------------------------------------------------------------------------------- /src/queries/getPages.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | // Get all daily pages plus pages that have at least one link 4 | const GET_PAGES = gql` 5 | query GetPages { 6 | page( 7 | where: { _or: [{ references: {} }, { isDaily: { _eq: true } }] } 8 | order_by: { updated_at: desc } 9 | ) { 10 | node 11 | title 12 | updated_at 13 | references_aggregate { 14 | aggregate { 15 | count 16 | } 17 | } 18 | } 19 | } 20 | `; 21 | 22 | export default GET_PAGES; 23 | -------------------------------------------------------------------------------- /src/queries/getPagesByTitle.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | // Until I set up a way to delete pages that aren't referenced, this query contains that logic 4 | const GET_PAGES_BY_TITLE = gql` 5 | query GetPagesByTitle($title: String) { 6 | page( 7 | where: { 8 | _and: [ 9 | { title: { _ilike: $title } } 10 | { _or: [{ references: {} }, { isDaily: { _eq: true } }] } 11 | ] 12 | } 13 | ) { 14 | title 15 | references { 16 | id 17 | } 18 | } 19 | } 20 | `; 21 | 22 | export default GET_PAGES_BY_TITLE; 23 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export function arraysEqual(a: any, b: any) { 2 | if (a === b) return true; 3 | if (a == null || b == null) return false; 4 | if (a.length !== b.length) return false; 5 | 6 | for (var i = 0; i < a.length; ++i) { 7 | if (a[i] !== b[i]) return false; 8 | } 9 | return true; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/datetime.ts: -------------------------------------------------------------------------------- 1 | export const todayDateString = () => new Date().toDateString(); 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | extend: {}, 4 | }, 5 | variants: {}, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "downlevelIteration": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------