├── .env.example ├── .eslintrc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── images.yml │ └── notfoundbot.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── public ├── icons │ └── favicon.ico ├── images │ ├── banner.png │ └── docs-blank.png └── screenshots │ ├── editor.PNG │ └── home.PNG ├── src ├── components │ ├── buttons │ │ └── Button.tsx │ ├── header │ │ ├── Header.tsx │ │ ├── HeaderLeft.tsx │ │ ├── HeaderRight.tsx │ │ └── HeaderSearch.tsx │ ├── icon │ │ ├── Icon.tsx │ │ └── IconButton.tsx │ ├── modals │ │ └── Modal.tsx │ └── wrappers │ │ └── DefaultWrapper.tsx ├── configs │ └── firebase.ts ├── features │ ├── auth │ │ ├── IsAuth.tsx │ │ ├── IsNotAuth.tsx │ │ └── LoginPage.tsx │ ├── document │ │ ├── editor │ │ │ ├── EditorHeader.tsx │ │ │ ├── EditorPage.tsx │ │ │ └── TextEditor.tsx │ │ ├── recent │ │ │ ├── DocumentsSection.tsx │ │ │ ├── RecentDocument.tsx │ │ │ └── RecentDocuments.tsx │ │ └── start │ │ │ ├── BlankDocument.tsx │ │ │ ├── CreateDocument.tsx │ │ │ └── StartDocumentSection.tsx │ └── home │ │ └── HomePage.tsx ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth].ts │ ├── doc │ │ └── [id].tsx │ ├── index.tsx │ └── login.tsx ├── services │ └── documentServices.tsx ├── styles │ └── globals.css └── types │ └── env.d.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Firebase 2 | NEXT_PUBLIC_FIREBASE_API_KEY= 3 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 4 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 5 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= 6 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= 7 | NEXT_PUBLIC_FIREBASE_APP_ID= 8 | NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= 9 | 10 | # Authentication 11 | GOOGLE_CLIENT_ID= 12 | GOOGLE_CLIENT_SECRET= 13 | NEXTAUTH_URL= 14 | NEXTAUTH_SECRET= -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: "0 13 * * 3" 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ["javascript"] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/images.yml: -------------------------------------------------------------------------------- 1 | name: Compress Images 2 | on: 3 | pull_request: 4 | # Run Image Actions when JPG, JPEG, PNG or WebP files are added or changed. 5 | # See https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#onpushpull_requestpaths for reference. 6 | paths: 7 | - "**.jpg" 8 | - "**.jpeg" 9 | - "**.png" 10 | - "**.webp" 11 | jobs: 12 | build: 13 | # Only run on Pull Requests within the same repository, and not from forks. 14 | if: github.event.pull_request.head.repo.full_name == github.repository 15 | name: calibreapp/image-actions 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout Repo 19 | uses: actions/checkout@v2 20 | 21 | - name: Compress Images 22 | uses: calibreapp/image-actions@main 23 | with: 24 | # The `GITHUB_TOKEN` is automatically generated by GitHub and scoped only to the repository that is currently running the action. By default, the action can’t update Pull Requests initiated from forked repositories. 25 | # See https://docs.github.com/en/actions/reference/authentication-in-a-workflow and https://help.github.com/en/articles/virtual-environments-for-github-actions#token-permissions 26 | githubToken: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/notfoundbot.yml: -------------------------------------------------------------------------------- 1 | name: notfoundbot 2 | on: 3 | schedule: 4 | - cron: "0 5 * * *" 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Fix links 11 | uses: tmcw/notfoundbot@v2.0.0-beta.1 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Martin Velkov 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 |

2 | 3 | Google Docs Logo 4 | 5 |
6 | Google Docs Clone 7 |
8 |

9 | 10 |

11 | Google Docs Clone created with Next.JS. 12 |

13 | 14 |

15 | Release 16 | Deployment 17 | License 18 |

19 | 20 |

21 | Demo • 22 | Key Features • 23 | Key Technologies • 24 | Setup • 25 | Support • 26 | License 27 |

28 | 29 | ![Text Editor Screenshot](public/screenshots/editor.PNG?raw=true "Text Editor Screenshot") 30 | 31 | --- 32 | 33 | ![Home Screenshot](public/screenshots/home.PNG?raw=true "Home Screenshot") 34 | 35 | --- 36 | 37 | ## Demo 38 | Here is a working live demo [here](https://google-docs-clone-martstech.vercel.app/) 39 | 40 | --- 41 | 42 | ## Key Features 43 | 44 | - Rich Text Editor 45 | - Documents stored real time 46 | - Autosave 47 | - Authentication 48 | - Responsive Design 49 | 50 | --- 51 | 52 | ## Key Technologies 53 | 54 | - Next.JS 55 | - Firebase 56 | - TailwindCSS 57 | - NextAuth 58 | - Typescript 59 | 60 | --- 61 | 62 | ## Setup 63 | 64 | Clone this repo to your desktop and run `yarn install` to install all the dependencies. 65 | Then run `yarn dev` to start the application locally 66 | 67 | Change the .env.example file to .env.local and fill the empty fields 68 | 69 | --- 70 | 71 | ## Support 72 | 73 | Whether you use this project, have learned something from it, or just like it, please consider supporting it by buying me a coffee, so I can dedicate more time on open-source projects like this :) 74 | 75 | 76 | Buy Me A Coffee 77 | 78 | 79 | --- 80 | 81 | ## License 82 | 83 | >You can check out the full license [here](https://github.com/MartsTech/google-docs-clone/blob/main/LICENSE) 84 | 85 | This project is licensed under the terms of the **MIT** license. 86 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | DM me on the contacts provided in my profile page: https://github.com/MartsTech 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-docs-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "gen-env-types": "npx gen-env-types .env.local -o src/types/env.d.ts -e ." 11 | }, 12 | "dependencies": { 13 | "@material-tailwind/react": "0.3.4", 14 | "@next-auth/firebase-adapter": "^0.1.3", 15 | "draft-js": "^0.11.7", 16 | "firebase": "^8.10.0", 17 | "moment": "^2.29.1", 18 | "next": "12.0.4", 19 | "next-auth": "^4.0.2", 20 | "react": "17.0.2", 21 | "react-dom": "17.0.2", 22 | "react-draft-wysiwyg": "^1.14.7", 23 | "react-firebase-hooks": "^3.0.4" 24 | }, 25 | "devDependencies": { 26 | "@types/draft-js": "^0.11.7", 27 | "@types/react": "^17.0.37", 28 | "autoprefixer": "^10.4.0", 29 | "eslint": "7.32.0", 30 | "eslint-config-next": "12.0.4", 31 | "gen-env-types": "^1.3.0", 32 | "postcss": "^8.4.4", 33 | "tailwindcss": "^2.2.19", 34 | "typescript": "^4.5.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartsTech/google-docs-clone/255bac4f6139c2c408d7b26b47c461489a100356/public/icons/favicon.ico -------------------------------------------------------------------------------- /public/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartsTech/google-docs-clone/255bac4f6139c2c408d7b26b47c461489a100356/public/images/banner.png -------------------------------------------------------------------------------- /public/images/docs-blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartsTech/google-docs-clone/255bac4f6139c2c408d7b26b47c461489a100356/public/images/docs-blank.png -------------------------------------------------------------------------------- /public/screenshots/editor.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartsTech/google-docs-clone/255bac4f6139c2c408d7b26b47c461489a100356/public/screenshots/editor.PNG -------------------------------------------------------------------------------- /public/screenshots/home.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MartsTech/google-docs-clone/255bac4f6139c2c408d7b26b47c461489a100356/public/screenshots/home.PNG -------------------------------------------------------------------------------- /src/components/buttons/Button.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import MaterialButton from "@material-tailwind/react/Button"; 3 | 4 | interface ButtonProps { 5 | type: "filled" | "link"; 6 | ripple: "light" | "dark"; 7 | color: string; 8 | onClick?: () => void; 9 | className?: string; 10 | } 11 | 12 | const Button: React.FC = ({ 13 | children, 14 | type, 15 | ripple, 16 | color, 17 | onClick, 18 | className, 19 | }) => { 20 | return ( 21 | 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | export default Button; 34 | -------------------------------------------------------------------------------- /src/components/header/Header.tsx: -------------------------------------------------------------------------------- 1 | import HeaderLeft from "./HeaderLeft"; 2 | import HeaderRight from "./HeaderRight"; 3 | import HeaderSearch from "./HeaderSearch"; 4 | 5 | interface HeaderProps {} 6 | 7 | const Header: React.FC = ({}) => { 8 | return ( 9 |
13 | 14 | 15 | 16 |
17 | ); 18 | }; 19 | 20 | export default Header; 21 | -------------------------------------------------------------------------------- /src/components/header/HeaderLeft.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@component/icon/Icon"; 2 | import IconButton from "@component/icon/IconButton"; 3 | 4 | interface HeaderLeftProps {} 5 | 6 | const HeaderLeft: React.FC = () => { 7 | return ( 8 | <> 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 |

20 | Docs 21 |

22 | 23 | ); 24 | }; 25 | 26 | export default HeaderLeft; 27 | -------------------------------------------------------------------------------- /src/components/header/HeaderRight.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@component/icon/Icon"; 2 | import IconButton from "@component/icon/IconButton"; 3 | import { signOut, useSession } from "next-auth/react"; 4 | 5 | interface HeaderRightProps {} 6 | 7 | const HeaderRight: React.FC = () => { 8 | const session = useSession(); 9 | 10 | return ( 11 | <> 12 | 16 | 17 | 18 | 19 | {/* eslint-disable-next-line @next/next/no-img-element*/} 20 | signOut()} 25 | alt="avatar" 26 | /> 27 | 28 | ); 29 | }; 30 | 31 | export default HeaderRight; 32 | -------------------------------------------------------------------------------- /src/components/header/HeaderSearch.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@component/icon/Icon"; 2 | 3 | interface HeaderSearchProps {} 4 | 5 | const HeaderSearch: React.FC = () => { 6 | return ( 7 |
12 | 13 | 18 |
19 | ); 20 | }; 21 | 22 | export default HeaderSearch; 23 | -------------------------------------------------------------------------------- /src/components/icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import MaterialIcon from "@material-tailwind/react/Icon"; 3 | 4 | interface IconProps { 5 | name: string; 6 | size: string; 7 | color?: string; 8 | } 9 | 10 | const Icon: React.FC = ({ name, size, color }) => { 11 | return ; 12 | }; 13 | 14 | export default Icon; 15 | -------------------------------------------------------------------------------- /src/components/icon/IconButton.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import Button from "@material-tailwind/react/Button"; 3 | 4 | interface ButtonProps { 5 | rounded?: boolean; 6 | className?: string; 7 | } 8 | 9 | const IconButton: React.FC = ({ 10 | children, 11 | rounded = false, 12 | className, 13 | }) => { 14 | return ( 15 | 25 | ); 26 | }; 27 | 28 | export default IconButton; 29 | -------------------------------------------------------------------------------- /src/components/modals/Modal.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import MaterialModal from "@material-tailwind/react/Modal"; 3 | // @ts-ignore 4 | import ModalBody from "@material-tailwind/react/ModalBody"; 5 | // @ts-ignore 6 | import ModalFooter from "@material-tailwind/react/ModalFooter"; 7 | 8 | interface ModalProps { 9 | active: boolean; 10 | onClose: () => void; 11 | Body: JSX.Element; 12 | Footer: JSX.Element; 13 | } 14 | 15 | const Modal: React.FC = ({ active, onClose, Body, Footer }) => { 16 | return ( 17 | 18 | {Body} 19 | {Footer} 20 | 21 | ); 22 | }; 23 | 24 | export default Modal; 25 | -------------------------------------------------------------------------------- /src/components/wrappers/DefaultWrapper.tsx: -------------------------------------------------------------------------------- 1 | interface DefaultWrapperProps {} 2 | 3 | const DefaultWrapper: React.FC = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | 7 | export default DefaultWrapper; 8 | -------------------------------------------------------------------------------- /src/configs/firebase.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/firestore"; 3 | 4 | const firebaseConfig = { 5 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 6 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, 7 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 8 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, 9 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, 10 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, 11 | measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, 12 | }; 13 | 14 | const firebaseApp = !firebase.apps.length 15 | ? firebase.initializeApp(firebaseConfig) 16 | : firebase.app(); 17 | 18 | const db = firebaseApp.firestore(); 19 | 20 | export { db }; 21 | 22 | -------------------------------------------------------------------------------- /src/features/auth/IsAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect } from "react"; 4 | 5 | interface IsAuthProps {} 6 | 7 | const IsAuth: React.FC = ({ children }) => { 8 | const session = useSession(); 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | if (session.status === "unauthenticated") { 13 | router.replace("/login"); 14 | } 15 | }, [session, router]); 16 | 17 | return <>{children}; 18 | }; 19 | 20 | export default IsAuth; 21 | -------------------------------------------------------------------------------- /src/features/auth/IsNotAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect } from "react"; 4 | 5 | interface IsNotAuthProps {} 6 | 7 | const IsNotAuth: React.FC = ({ children }) => { 8 | const session = useSession(); 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | if (session.status === "authenticated") { 13 | router.replace("/"); 14 | } 15 | }, [session, router]); 16 | 17 | return <>{children}; 18 | }; 19 | 20 | export default IsNotAuth; 21 | -------------------------------------------------------------------------------- /src/features/auth/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@component/buttons/Button"; 2 | import { signIn } from "next-auth/react"; 3 | import Image from "next/image"; 4 | 5 | interface LoginPageProps {} 6 | 7 | const LoginPage: React.FC = () => { 8 | return ( 9 |
13 | banner 20 | 29 |
33 | Disclaimer: This is not the official Google Docs. It is 34 | a redesign, built purely for educational purpose. 35 |
36 |
37 | ); 38 | }; 39 | 40 | export default LoginPage; 41 | -------------------------------------------------------------------------------- /src/features/document/editor/EditorHeader.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@component/buttons/Button"; 2 | import Icon from "@component/icon/Icon"; 3 | import { useSession } from "next-auth/react"; 4 | import { useRouter } from "next/router"; 5 | 6 | interface EditorHeaderProps { 7 | filename: string; 8 | } 9 | 10 | const EditorHeader: React.FC = ({ filename }) => { 11 | const router = useRouter(); 12 | const session = useSession(); 13 | 14 | return ( 15 |
16 | router.push("/")} className="cursor-pointer"> 17 | 18 | 19 |
20 |

{filename}

21 |
25 |

File

26 |

Edit

27 |

View

28 |

Insert

29 |

Format

30 |

Tools

31 |
32 |
33 | 42 | 43 | {/*eslint-disable-next-line @next/next/no-img-element*/} 44 | avatar 49 |
50 | ); 51 | }; 52 | 53 | export default EditorHeader; 54 | -------------------------------------------------------------------------------- /src/features/document/editor/EditorPage.tsx: -------------------------------------------------------------------------------- 1 | import firebase from "firebase"; 2 | import EditorHeader from "./EditorHeader"; 3 | import TextEditor from "./TextEditor"; 4 | 5 | interface EditorPageProps { 6 | snapshot: 7 | | firebase.firestore.DocumentSnapshot 8 | | undefined; 9 | } 10 | 11 | const EditorPage: React.FC = ({ snapshot }) => { 12 | return ( 13 | <> 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default EditorPage; 21 | -------------------------------------------------------------------------------- /src/features/document/editor/TextEditor.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@config/firebase"; 2 | import { convertFromRaw, convertToRaw, EditorState } from "draft-js"; 3 | import { useSession } from "next-auth/react"; 4 | import dynamic from "next/dynamic"; 5 | import { useRouter } from "next/router"; 6 | import { useEffect, useState } from "react"; 7 | import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css"; 8 | import { useDocumentOnce } from "react-firebase-hooks/firestore"; 9 | 10 | const Editor = dynamic( 11 | // @ts-ignore 12 | () => import("react-draft-wysiwyg").then((module) => module.Editor), 13 | { ssr: false } 14 | ); 15 | 16 | interface TextEditorProps {} 17 | 18 | const TextEditor: React.FC = () => { 19 | const [editorState, setEditorState] = useState(EditorState.createEmpty()); 20 | 21 | const session = useSession(); 22 | 23 | const router = useRouter(); 24 | const { id } = router.query; 25 | 26 | const [snapshot] = useDocumentOnce( 27 | db 28 | .collection("userDocs") 29 | .doc(session?.data?.user?.email as string) 30 | .collection("docs") 31 | .doc(id as string) 32 | ); 33 | 34 | useEffect(() => { 35 | if (snapshot?.data()?.editorState) { 36 | setEditorState( 37 | EditorState.createWithContent( 38 | convertFromRaw(snapshot?.data()?.editorState) 39 | ) 40 | ); 41 | } 42 | }, [snapshot]); 43 | 44 | const onEditorStateChange = (editorState: EditorState) => { 45 | setEditorState(editorState); 46 | 47 | db.collection("userDocs") 48 | .doc(session?.data?.user?.email as string) 49 | .collection("docs") 50 | .doc(id as string) 51 | .set( 52 | { 53 | editorState: convertToRaw(editorState.getCurrentContent()), 54 | }, 55 | { 56 | merge: true, 57 | } 58 | ); 59 | }; 60 | 61 | return ( 62 |
63 | {/*@ts-ignore*/} 64 | 73 |
74 | ); 75 | }; 76 | 77 | export default TextEditor; 78 | -------------------------------------------------------------------------------- /src/features/document/recent/DocumentsSection.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@component/icon/Icon"; 2 | import RecentDocuments from "./RecentDocuments"; 3 | 4 | interface DocumentsSectionProps {} 5 | 6 | const DocumentsSection: React.FC = () => { 7 | return ( 8 |
9 |
10 |
11 |

My Documents

12 |

Date Created

13 | 14 |
15 |
16 | 17 |
18 | ); 19 | }; 20 | 21 | export default DocumentsSection; 22 | -------------------------------------------------------------------------------- /src/features/document/recent/RecentDocument.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@component/icon/Icon"; 2 | import IconButton from "@component/icon/IconButton"; 3 | import { useRouter } from "next/router"; 4 | 5 | interface RecentDocumentProps { 6 | id: string; 7 | filename: string; 8 | date: string; 9 | } 10 | 11 | const RecentDocument: React.FC = ({ 12 | id, 13 | filename, 14 | date, 15 | }) => { 16 | const router = useRouter(); 17 | 18 | return ( 19 |
router.push(`/doc/${id}`)} 23 | > 24 | 25 |

{filename}

26 |

{date}

27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | export default RecentDocument; 35 | -------------------------------------------------------------------------------- /src/features/document/recent/RecentDocuments.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@config/firebase"; 2 | import moment from "moment"; 3 | import { useSession } from "next-auth/react"; 4 | import { useCollectionOnce } from "react-firebase-hooks/firestore"; 5 | import RecentDocument from "./RecentDocument"; 6 | 7 | interface RecentDocumentsProps {} 8 | 9 | const RecentDocuments: React.FC = ({}) => { 10 | const session = useSession(); 11 | const [snapshot] = useCollectionOnce( 12 | db 13 | .collection("userDocs") 14 | .doc(session?.data?.user?.email as string) 15 | .collection("docs") 16 | .orderBy("timestamp", "desc") 17 | ); 18 | 19 | return ( 20 | <> 21 | {snapshot?.docs.map((doc) => ( 22 | 28 | ))} 29 | 30 | ); 31 | }; 32 | 33 | export default RecentDocuments; 34 | -------------------------------------------------------------------------------- /src/features/document/start/BlankDocument.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useState } from "react"; 3 | import CreateDocument from "./CreateDocument"; 4 | 5 | interface BlankDocumentProps {} 6 | 7 | const BlankDocument: React.FC = () => { 8 | const [showModal, setShowModal] = useState(false); 9 | 10 | return ( 11 | <> 12 | setShowModal(false)} /> 13 |
setShowModal(!showModal)} 18 | > 19 | blank 20 |
21 |

Blank

22 | 23 | ); 24 | }; 25 | 26 | export default BlankDocument; 27 | -------------------------------------------------------------------------------- /src/features/document/start/CreateDocument.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@component/buttons/Button"; 2 | import Modal from "@component/modals/Modal"; 3 | import { createDocument } from "@service/documentServices"; 4 | import { useSession } from "next-auth/react"; 5 | import { useState } from "react"; 6 | 7 | interface CreateDocumentProps { 8 | showModal: boolean; 9 | close: () => void; 10 | } 11 | 12 | const CreateDocument: React.FC = ({ 13 | showModal, 14 | close, 15 | }) => { 16 | const [input, setInput] = useState(""); 17 | const session = useSession(); 18 | 19 | const create = () => { 20 | createDocument(input, session?.data?.user?.email || ""); 21 | setInput(""); 22 | close(); 23 | }; 24 | 25 | return ( 26 | setInput(e.target.value)} 33 | type="text" 34 | className="outline-none w-full" 35 | placeholder="Enter name of document..." 36 | onKeyDown={(e) => e.key === "Enter" && create()} 37 | /> 38 | } 39 | Footer={ 40 | <> 41 | 44 | 52 | 53 | } 54 | /> 55 | ); 56 | }; 57 | 58 | export default CreateDocument; 59 | -------------------------------------------------------------------------------- /src/features/document/start/StartDocumentSection.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@component/icon/Icon"; 2 | import IconButton from "@component/icon/IconButton"; 3 | import BlankDocument from "./BlankDocument"; 4 | 5 | interface StartDocumentSectionProps {} 6 | 7 | const StartDocumentSection: React.FC = () => { 8 | return ( 9 |
10 |
11 |

Start a new document

12 | 13 | 14 | 15 |
16 | 17 |
18 | ); 19 | }; 20 | 21 | export default StartDocumentSection; 22 | -------------------------------------------------------------------------------- /src/features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@component/header/Header"; 2 | import DefaultWrapper from "@component/wrappers/DefaultWrapper"; 3 | import DocumentsSection from "@feature/document/recent/DocumentsSection"; 4 | import StartDocumentSection from "@feature/document/start/StartDocumentSection"; 5 | 6 | interface HomePageProps {} 7 | 8 | const HomePage: React.FC = () => { 9 | return ( 10 | <> 11 |
12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default HomePage; 21 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useEffect } from "react"; 3 | 4 | const Custom404 = () => { 5 | const router = useRouter(); 6 | 7 | useEffect(() => { 8 | router.replace("/"); 9 | }); 10 | 11 | return null; 12 | }; 13 | 14 | export default Custom404; 15 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@material-tailwind/react/tailwind.css"; 2 | import "@style/globals.css"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import type { AppProps } from "next/app"; 5 | import Head from "next/head"; 6 | 7 | const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps) => { 8 | return ( 9 | 10 | 11 | Google Docs Clone 12 | 13 | {/*@ts-ignore*/} 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import NextDocument, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | class Document extends NextDocument { 4 | render() { 5 | return ( 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | ); 38 | } 39 | } 40 | 41 | export default Document; 42 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { db } from "@config/firebase"; 2 | import { FirebaseAdapter } from "@next-auth/firebase-adapter"; 3 | import NextAuth from "next-auth"; 4 | import GoogleProvider from "next-auth/providers/google"; 5 | 6 | export default NextAuth({ 7 | secret: process.env.NEXTAUTH_SECRET, 8 | providers: [ 9 | GoogleProvider({ 10 | clientId: process.env.GOOGLE_CLIENT_ID, 11 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 12 | }), 13 | ], 14 | adapter: FirebaseAdapter(db), 15 | }); 16 | -------------------------------------------------------------------------------- /src/pages/doc/[id].tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@config/firebase"; 2 | import IsAuth from "@feature/auth/IsAuth"; 3 | import EditorPage from "@feature/document/editor/EditorPage"; 4 | import { GetServerSideProps } from "next"; 5 | import { getSession, useSession } from "next-auth/react"; 6 | import Head from "next/head"; 7 | import { useRouter } from "next/router"; 8 | import { useDocumentOnce } from "react-firebase-hooks/firestore"; 9 | 10 | interface DocProps {} 11 | 12 | const Doc: React.FC = () => { 13 | const session = useSession(); 14 | 15 | const router = useRouter(); 16 | const { id } = router.query; 17 | 18 | const [snapshot, loading] = useDocumentOnce( 19 | db 20 | .collection("userDocs") 21 | .doc(session?.data?.user?.email as string) 22 | .collection("docs") 23 | .doc(id as string) 24 | ); 25 | 26 | if (!loading && !snapshot?.data()?.filename) { 27 | router.replace("/"); 28 | } 29 | 30 | return ( 31 | 32 | 33 | {snapshot?.data()?.filename} 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Doc; 41 | 42 | export const getServerSideProps: GetServerSideProps = async (context) => { 43 | const session = await getSession(context); 44 | 45 | return { 46 | props: { 47 | session, 48 | }, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import IsAuth from "@feature/auth/IsAuth"; 2 | import HomePage from "@feature/home/HomePage"; 3 | import { GetServerSideProps } from "next"; 4 | import { getSession } from "next-auth/react"; 5 | 6 | interface HomeProps {} 7 | 8 | const Home: React.FC = () => { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default Home; 17 | 18 | export const getServerSideProps: GetServerSideProps = async (context) => { 19 | const session = await getSession(context); 20 | 21 | return { 22 | props: { 23 | session, 24 | }, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import IsNotAuth from "features/auth/IsNotAuth"; 2 | import LoginPage from "features/auth/LoginPage"; 3 | import { GetServerSideProps } from "next"; 4 | import { getSession } from "next-auth/react"; 5 | import Head from "next/head"; 6 | 7 | interface LoginProps {} 8 | 9 | const Login: React.FC = () => { 10 | return ( 11 | 12 | 13 | Login 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Login; 21 | 22 | export const getServerSideProps: GetServerSideProps = async (context) => { 23 | const session = await getSession(context); 24 | 25 | return { 26 | props: { 27 | session, 28 | }, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/services/documentServices.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@config/firebase"; 2 | import firebase from "firebase"; 3 | 4 | export const createDocument = (filename: string, email: string) => { 5 | if (filename == "" || email == "") return; 6 | 7 | db.collection("userDocs").doc(email).collection("docs").add({ 8 | filename, 9 | timestamp: firebase.firestore.FieldValue.serverTimestamp(), 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .option { 7 | @apply cursor-pointer hover:bg-gray-100 transition duration-200 8 | ease-out p-2 rounded-lg; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NEXT_PUBLIC_FIREBASE_API_KEY: string; 5 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: string; 6 | NEXT_PUBLIC_FIREBASE_PROJECT_ID: string; 7 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: string; 8 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: string; 9 | NEXT_PUBLIC_FIREBASE_APP_ID: string; 10 | NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: string; 11 | GOOGLE_CLIENT_ID: string; 12 | GOOGLE_CLIENT_SECRET: string; 13 | NEXTAUTH_URL: string; 14 | NEXTAUTH_SECRET: string; 15 | } 16 | } 17 | } 18 | 19 | export {} 20 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: "jit", 3 | purge: ["./src/**/*.tsx"], 4 | darkMode: "class", 5 | theme: { 6 | extend: {}, 7 | }, 8 | variants: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "@component/*": [ 6 | "components/*" 7 | ], 8 | "@config/*": [ 9 | "configs/*" 10 | ], 11 | "@feature/*": [ 12 | "features/*" 13 | ], 14 | "@service/*": [ 15 | "services/*" 16 | ], 17 | "@type/*": [ 18 | "types/*" 19 | ], 20 | "@style/*": [ 21 | "styles/*" 22 | ] 23 | }, 24 | "outDir": "build/dist", 25 | "module": "esnext", 26 | "target": "es5", 27 | "lib": [ 28 | "es6", 29 | "dom", 30 | "esnext.asynciterable" 31 | ], 32 | "sourceMap": true, 33 | "allowJs": true, 34 | "jsx": "preserve", 35 | "moduleResolution": "node", 36 | "rootDir": "src", 37 | "forceConsistentCasingInFileNames": true, 38 | "noImplicitReturns": true, 39 | "noImplicitThis": true, 40 | "noImplicitAny": true, 41 | "strictNullChecks": true, 42 | "suppressImplicitAnyIndexErrors": true, 43 | "noUnusedLocals": true, 44 | "skipLibCheck": true, 45 | "strict": false, 46 | "noEmit": true, 47 | "esModuleInterop": true, 48 | "resolveJsonModule": true, 49 | "isolatedModules": true, 50 | "incremental": true 51 | }, 52 | "exclude": [ 53 | "node_modules", 54 | "build", 55 | "scripts", 56 | "acceptance-tests", 57 | "webpack", 58 | "jest", 59 | "src/setupTests.ts" 60 | ], 61 | "include": [ 62 | "next-env.d.ts", 63 | "**/*.ts", 64 | "**/*.tsx", 65 | "src/components/header/Header.tsx" 66 | ] 67 | } 68 | --------------------------------------------------------------------------------