├── client ├── src │ ├── vite-env.d.ts │ ├── assets │ │ ├── css │ │ │ ├── index.css │ │ │ ├── tailwind.css │ │ │ └── fonts.css │ │ └── fonts │ │ │ ├── icons │ │ │ ├── icons.eot │ │ │ ├── icons.ttf │ │ │ └── icons.woff │ │ │ └── open-sans │ │ │ ├── OpenSans-Bold.ttf │ │ │ ├── OpenSans-Light.ttf │ │ │ ├── OpenSans-Medium.ttf │ │ │ ├── OpenSans-Regular.ttf │ │ │ ├── OpenSans-SemiBold.ttf │ │ │ └── OFL.txt │ ├── pages │ │ ├── NotFound │ │ │ └── index.tsx │ │ ├── SheetDetail │ │ │ └── index.tsx │ │ ├── SignIn │ │ │ ├── SignIn.module.scss │ │ │ └── index.tsx │ │ ├── SignUp │ │ │ ├── SignUp.module.scss │ │ │ └── index.tsx │ │ └── SheetList │ │ │ └── index.tsx │ ├── main.tsx │ ├── types │ │ ├── User.ts │ │ └── Sheets.ts │ ├── hooks │ │ ├── useTitle.ts │ │ └── useAuth.tsx │ ├── layouts │ │ └── AuthLayout.tsx │ ├── components │ │ ├── Sheet │ │ │ ├── Grid │ │ │ │ ├── RowOverLay.tsx │ │ │ │ ├── ColumnOverLay.tsx │ │ │ │ ├── HighLightRow.tsx │ │ │ │ ├── HighLightColumn.tsx │ │ │ │ ├── EditCell.tsx │ │ │ │ ├── HighLightSearchCells.tsx │ │ │ │ ├── HighLightCell.tsx │ │ │ │ ├── ScrollBar.tsx │ │ │ │ ├── RowResizer.tsx │ │ │ │ ├── ColumnResizer.tsx │ │ │ │ ├── ContextMenu.tsx │ │ │ │ ├── AutoFill.tsx │ │ │ │ └── ColorPicker.tsx │ │ │ ├── index.tsx │ │ │ ├── Header.tsx │ │ │ ├── BottomBar │ │ │ │ ├── index.tsx │ │ │ │ └── GridCard.tsx │ │ │ └── ToolBar.tsx │ │ ├── Loader │ │ │ └── index.tsx │ │ ├── Avatar │ │ │ └── index.tsx │ │ ├── Input │ │ │ ├── index.tsx │ │ │ └── Input.module.scss │ │ └── Pagination │ │ │ ├── index.tsx │ │ │ └── Pagination.scss │ ├── services │ │ ├── config.ts │ │ ├── User.ts │ │ ├── Row.ts │ │ ├── Column.ts │ │ ├── axios.ts │ │ ├── Sheet.ts │ │ ├── Grid.ts │ │ └── Cell.ts │ ├── router │ │ ├── ProtectedRoute.tsx │ │ └── index.tsx │ ├── App.tsx │ ├── utils │ │ └── index.ts │ └── constants │ │ └── index.ts ├── public │ ├── logo.png │ ├── favicon.ico │ ├── plus.svg │ └── account.svg ├── postcss.config.js ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── .eslintrc.cjs ├── index.html ├── tsconfig.json ├── tailwind.config.js └── package.json ├── server ├── nodemon.json ├── src │ ├── routes │ │ ├── user.ts │ │ ├── row.ts │ │ ├── column.ts │ │ ├── sheet.ts │ │ ├── grid.ts │ │ ├── index.ts │ │ └── cell.ts │ ├── database │ │ └── config.ts │ ├── models │ │ ├── row.ts │ │ ├── column.ts │ │ ├── user.ts │ │ ├── grid.ts │ │ ├── sheet.ts │ │ └── cell.ts │ ├── middlewares │ │ ├── verifyToken.ts │ │ └── cors.ts │ ├── controllers │ │ ├── row.ts │ │ ├── column.ts │ │ ├── user.ts │ │ ├── sheet.ts │ │ ├── grid.ts │ │ └── cell.ts │ └── utils │ │ └── index.ts ├── vercel.json ├── .gitignore ├── index.ts ├── package.json └── tsconfig.json └── .prettierrc /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/public/logo.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/src/assets/css/index.css: -------------------------------------------------------------------------------- 1 | @import "./fonts.css"; 2 | @import "./icons.css"; 3 | @import "./tailwind.css"; 4 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/assets/fonts/icons/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/icons/icons.eot -------------------------------------------------------------------------------- /client/src/assets/fonts/icons/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/icons/icons.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/icons/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/icons/icons.woff -------------------------------------------------------------------------------- /client/src/pages/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | const PageNotFound = () => { 2 | return
Page Not Found
; 3 | }; 4 | 5 | export default PageNotFound; 6 | -------------------------------------------------------------------------------- /client/src/assets/fonts/open-sans/OpenSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/open-sans/OpenSans-Bold.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/open-sans/OpenSans-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/open-sans/OpenSans-Light.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/open-sans/OpenSans-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/open-sans/OpenSans-Medium.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/open-sans/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/open-sans/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/open-sans/OpenSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vkaswin/google-sheets/HEAD/client/src/assets/fonts/open-sans/OpenSans-SemiBold.ttf -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src/**/*.ts", 4 | "./index.ts" 5 | ], 6 | "ignore": [ 7 | "src/**/*.spec.ts" 8 | ], 9 | "exec": "ts-node ./index.ts" 10 | } -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "./App"; 3 | 4 | let container = document.getElementById("root") as HTMLElement; 5 | let root = createRoot(container); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /client/src/types/User.ts: -------------------------------------------------------------------------------- 1 | type IUser = { 2 | _id: string; 3 | name: string; 4 | email: string; 5 | colorCode: string; 6 | }; 7 | 8 | type ISignIn = { 9 | email: string; 10 | password: string; 11 | }; 12 | 13 | type ISignUp = { 14 | name: string; 15 | } & ISignIn; 16 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /server/src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import UserController from "../controllers/user"; 3 | 4 | const router = Router(); 5 | 6 | router.post("/sign-in", UserController.signIn); 7 | router.post("/sign-up", UserController.signUp); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /client/src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | const useTitle = (title?: string) => { 4 | useEffect(() => { 5 | if (!document || !title || document.title === title) return; 6 | document.title = title; 7 | }, [title]); 8 | }; 9 | 10 | export default useTitle; 11 | -------------------------------------------------------------------------------- /client/src/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | 3 | const AuthLayout = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default AuthLayout; 12 | -------------------------------------------------------------------------------- /server/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "./index.ts", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "/" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /client/src/pages/SheetDetail/index.tsx: -------------------------------------------------------------------------------- 1 | import { SheetProvider } from "@/hooks/useSheet"; 2 | import Sheet from "@/components/Sheet"; 3 | 4 | const SheetDetail = () => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default SheetDetail; 13 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /server/src/routes/row.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import RowController from "../controllers/row"; 3 | import verifyToken from "../middlewares/verifyToken"; 4 | 5 | const router = Router(); 6 | 7 | router.use(verifyToken); 8 | router.post("/:gridId/create", RowController.createRow); 9 | router.put("/:rowId/update", RowController.updateRow); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /server/src/routes/column.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import ColumnController from "../controllers/column"; 3 | import verifyToken from "../middlewares/verifyToken"; 4 | 5 | const router = Router(); 6 | 7 | router.use(verifyToken); 8 | router.post("/:gridId/create", ColumnController.createColumn); 9 | router.put("/:columnId/update", ColumnController.updateColumn); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /client/public/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src/"), 11 | }, 12 | }, 13 | base: process.env.NODE_ENV === "production" ? "/google-sheets" : "/", 14 | server: { port: 3000 }, 15 | }); 16 | -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /server/.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 | .env 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | public 28 | /person.json -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/RowOverLay.tsx: -------------------------------------------------------------------------------- 1 | type IROwOverLayProps = { 2 | row: IRow; 3 | }; 4 | 5 | const RowOverLay = ({ row: { height, y } }: IROwOverLayProps) => { 6 | let top = `calc(${y}px - var(--row-height))`; 7 | 8 | return ( 9 |
13 | ); 14 | }; 15 | 16 | export default RowOverLay; 17 | -------------------------------------------------------------------------------- /client/src/services/config.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = 2 | process.env.NODE_ENV === "development" 3 | ? "http://localhost:8000" 4 | : "https://google-sheets-flax.vercel.app"; 5 | 6 | export const USER_URL = `${BASE_URL}/api/user`; 7 | export const SHEET_URL = `${BASE_URL}/api/sheet`; 8 | export const GRID_URL = `${BASE_URL}/api/grid`; 9 | export const COLUMN_URL = `${BASE_URL}/api/column`; 10 | export const ROW_URL = `${BASE_URL}/api/row`; 11 | export const CELL_URL = `${BASE_URL}/api/cell`; 12 | -------------------------------------------------------------------------------- /client/src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | const Loader = () => { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 | 10 | Loading... 11 | 12 |
13 | ); 14 | }; 15 | 16 | export default Loader; 17 | -------------------------------------------------------------------------------- /server/src/database/config.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const connect = async () => { 4 | mongoose.set("strictQuery", true); 5 | 6 | let uri = ( 7 | process.env.NODE_ENV === "development" 8 | ? process.env.MONGO_URI_DEV 9 | : process.env.MONGO_URI 10 | ) as string; 11 | 12 | let res = await mongoose.connect(uri); 13 | 14 | console.log( 15 | "🚀 ~ file: config.ts:17 ~ MongoDB connected ~ ", 16 | res.connection.host 17 | ); 18 | }; 19 | 20 | export default connect; 21 | -------------------------------------------------------------------------------- /server/src/models/row.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | const RowSchema = new Schema( 4 | { 5 | gridId: { 6 | required: true, 7 | index: true, 8 | type: Schema.Types.ObjectId, 9 | }, 10 | rowId: { 11 | required: true, 12 | type: Number, 13 | }, 14 | height: { 15 | required: true, 16 | type: Number, 17 | }, 18 | }, 19 | { timestamps: true } 20 | ); 21 | 22 | const Row = model("Row", RowSchema); 23 | 24 | export default Row; 25 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/ColumnOverLay.tsx: -------------------------------------------------------------------------------- 1 | type IColumnOverLayProps = { 2 | column: IColumn; 3 | }; 4 | 5 | const ColumnOverLay = ({ column: { width, x } }: IColumnOverLayProps) => { 6 | let left = `calc(${x}px - var(--col-width))`; 7 | 8 | return ( 9 |
13 | ); 14 | }; 15 | 16 | export default ColumnOverLay; 17 | -------------------------------------------------------------------------------- /server/src/models/column.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | const ColumnSchema = new Schema( 4 | { 5 | gridId: { 6 | required: true, 7 | index: true, 8 | type: Schema.Types.ObjectId, 9 | }, 10 | columnId: { 11 | required: true, 12 | type: Number, 13 | }, 14 | width: { 15 | required: true, 16 | type: Number, 17 | }, 18 | }, 19 | { timestamps: true } 20 | ); 21 | 22 | const Column = model("Column", ColumnSchema); 23 | 24 | export default Column; 25 | -------------------------------------------------------------------------------- /client/src/services/User.ts: -------------------------------------------------------------------------------- 1 | import axios from "./axios"; 2 | import { USER_URL } from "./config"; 3 | 4 | type IAuthResponse = { 5 | message: string; 6 | data: { token: string }; 7 | }; 8 | 9 | export const signInUser = (data: ISignIn) => { 10 | return axios({ 11 | url: `${USER_URL}/sign-in`, 12 | method: "post", 13 | data, 14 | }); 15 | }; 16 | 17 | export const signUpUser = (data: ISignUp) => { 18 | return axios({ 19 | url: `${USER_URL}/sign-up`, 20 | method: "post", 21 | data, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/services/Row.ts: -------------------------------------------------------------------------------- 1 | import axios from "./axios"; 2 | import { ROW_URL } from "./config"; 3 | 4 | export const createRow = (gridId: string, data: Partial) => { 5 | return axios<{ message: string; data: { rowId: string } }>({ 6 | url: `${ROW_URL}/${gridId}/create`, 7 | method: "post", 8 | data, 9 | }); 10 | }; 11 | 12 | export const updateRowById = (rowId: string, data: Partial) => { 13 | return axios<{ message: string }>({ 14 | url: `${ROW_URL}/${rowId}/update`, 15 | method: "put", 16 | data, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /server/src/routes/sheet.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import SheetController from "../controllers/sheet"; 3 | import verifyToken from "../middlewares/verifyToken"; 4 | 5 | const router = Router(); 6 | 7 | router.use(verifyToken); 8 | router.post("/create", SheetController.createSheet); 9 | router.get("/:sheetId/detail", SheetController.getSheetById); 10 | router.get("/list", SheetController.getSheetList); 11 | router.put("/:sheetId/update", SheetController.updateSheetById); 12 | router.delete("/:sheetId/remove", SheetController.removeSheetById); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import cors from "./src/middlewares/cors"; 4 | import connect from "./src/database/config"; 5 | import router from "./src/routes"; 6 | 7 | dotenv.config(); 8 | 9 | const port = process.env.PORT; 10 | const app = express(); 11 | 12 | app 13 | .use(cors) 14 | .use(express.json()) 15 | .use(express.urlencoded({ extended: false })) 16 | .use(router); 17 | 18 | connect().then(() => { 19 | app.listen(port, () => { 20 | console.log(`⚡️[server]: Server is running at http://localhost:${port}`); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /client/src/services/Column.ts: -------------------------------------------------------------------------------- 1 | import axios from "./axios"; 2 | import { COLUMN_URL } from "./config"; 3 | 4 | export const createColumn = (gridId: string, data: Partial) => { 5 | return axios<{ message: string; data: { columnId: string } }>({ 6 | url: `${COLUMN_URL}/${gridId}/create`, 7 | method: "post", 8 | data, 9 | }); 10 | }; 11 | 12 | export const updateColumnById = ( 13 | columnId: string, 14 | data: Partial 15 | ) => { 16 | return axios<{ message: string }>({ 17 | url: `${COLUMN_URL}/${columnId}/update`, 18 | method: "put", 19 | data, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "useTabs": false, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "semi": true, 7 | "singleQuote": false, 8 | "quoteProps": "as-needed", 9 | "jsxSingleQuote": false, 10 | "bracketSpacing": true, 11 | "bracketSameLine": false, 12 | "arrowParens": "always", 13 | "requirePragma": false, 14 | "insertPragma": false, 15 | "proseWrap": "preserve", 16 | "htmlWhitespaceSensitivity": "css", 17 | "vueIndentScriptAndStyle": false, 18 | "endOfLine": "lf", 19 | "embeddedLanguageFormatting": "auto", 20 | "singleAttributePerLine": false 21 | } -------------------------------------------------------------------------------- /server/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | import { generateRandomColor } from "../utils"; 3 | 4 | const UserSchema = new Schema( 5 | { 6 | name: { 7 | required: true, 8 | type: String, 9 | }, 10 | email: { 11 | required: true, 12 | type: String, 13 | }, 14 | password: { 15 | required: true, 16 | type: String, 17 | }, 18 | colorCode: { 19 | type: String, 20 | default: generateRandomColor, 21 | }, 22 | }, 23 | { 24 | timestamps: true, 25 | } 26 | ); 27 | 28 | const User = model("User", UserSchema); 29 | 30 | export default User; 31 | -------------------------------------------------------------------------------- /server/src/models/grid.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Types, model } from "mongoose"; 2 | 3 | const GridSchema = new Schema( 4 | { 5 | sheetId: { 6 | required: true, 7 | index: true, 8 | type: Schema.Types.ObjectId, 9 | }, 10 | title: { 11 | default: "Sheet 1", 12 | type: String, 13 | }, 14 | color: { 15 | default: "transparent", 16 | type: String, 17 | }, 18 | createdBy: { 19 | type: Types.ObjectId, 20 | ref: "User", 21 | required: true, 22 | }, 23 | }, 24 | { timestamps: true } 25 | ); 26 | 27 | const Grid = model("Grid", GridSchema); 28 | 29 | export default Grid; 30 | -------------------------------------------------------------------------------- /server/src/models/sheet.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Types } from "mongoose"; 2 | 3 | const SheetSchema = new Schema( 4 | { 5 | title: { 6 | default: "Untitled Spreadsheet", 7 | type: String, 8 | }, 9 | grids: { 10 | ref: "Grid", 11 | type: [Types.ObjectId], 12 | }, 13 | createdBy: { 14 | type: Types.ObjectId, 15 | ref: "User", 16 | required: true, 17 | }, 18 | lastOpenedAt: { 19 | type: Date, 20 | default: () => new Date().toISOString(), 21 | }, 22 | }, 23 | { timestamps: true } 24 | ); 25 | 26 | const Sheet = model("Sheet", SheetSchema); 27 | 28 | export default Sheet; 29 | -------------------------------------------------------------------------------- /server/src/routes/grid.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import GridController from "../controllers/grid"; 3 | import verifyToken from "../middlewares/verifyToken"; 4 | 5 | const router = Router(); 6 | 7 | router.use(verifyToken); 8 | 9 | router.get("/:gridId/detail", GridController.getGridById); 10 | router.get("/:gridId/search", GridController.searchGrid); 11 | 12 | router.post("/:sheetId/create", GridController.createGrid); 13 | router.post("/:gridId/duplicate", GridController.duplicateGridById); 14 | 15 | router.put("/:gridId/update", GridController.updateGridById); 16 | 17 | router.delete("/:gridId/remove", GridController.removeGridById); 18 | 19 | export default router; 20 | -------------------------------------------------------------------------------- /client/src/components/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar as ChakraUIAvatar, 3 | MenuList, 4 | MenuButton, 5 | Menu, 6 | MenuItem, 7 | Portal, 8 | } from "@chakra-ui/react"; 9 | 10 | type IAvatarProps = { 11 | user: IUser; 12 | logout: () => void; 13 | }; 14 | 15 | const Avatar = ({ user: { name, colorCode }, logout }: IAvatarProps) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | Logout 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Avatar; 31 | -------------------------------------------------------------------------------- /client/src/components/Sheet/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import Header from "./Header"; 3 | import ToolBar from "./ToolBar"; 4 | import BottomBar from "./BottomBar"; 5 | import Grid from "@/components/Sheet/Grid"; 6 | import Loader from "@/components/Loader"; 7 | import { useSheet } from "@/hooks/useSheet"; 8 | 9 | const Sheet = () => { 10 | const { isSheetLoading } = useSheet(); 11 | 12 | return ( 13 |
14 | {isSheetLoading ? ( 15 | 16 | ) : ( 17 | 18 |
19 | 20 | 21 | 22 | 23 | )} 24 |
25 | ); 26 | }; 27 | 28 | export default Sheet; 29 | -------------------------------------------------------------------------------- /client/src/services/axios.ts: -------------------------------------------------------------------------------- 1 | import Axios from "axios"; 2 | import { cookie } from "@/utils"; 3 | 4 | const axios = Axios.create({}); 5 | 6 | axios.interceptors.request.use( 7 | (config) => { 8 | let token = cookie.get("auth_token"); 9 | if (token) { 10 | config.headers["authorization"] = token; 11 | } 12 | return config; 13 | }, 14 | (error) => { 15 | return Promise.reject(error); 16 | } 17 | ); 18 | 19 | axios.interceptors.response.use( 20 | (response) => { 21 | return response; 22 | }, 23 | (error) => { 24 | if (error?.response?.status === 401) { 25 | document.dispatchEvent(new CustomEvent("unauthorized")); 26 | } 27 | 28 | return Promise.reject(error?.response?.data); 29 | } 30 | ); 31 | 32 | export default axios; 33 | -------------------------------------------------------------------------------- /client/src/router/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, ReactNode } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import { cookie } from "@/utils"; 4 | 5 | type ProtectedRouteProps = { 6 | children: ReactNode; 7 | isAuthenticated?: boolean; 8 | redirectIfLogin?: boolean; 9 | }; 10 | 11 | const ProtectedRoute = ({ 12 | children, 13 | isAuthenticated = true, 14 | redirectIfLogin = false, 15 | }: ProtectedRouteProps) => { 16 | let authToken = cookie.get("auth_token"); 17 | 18 | if (authToken && redirectIfLogin) 19 | return ; 20 | 21 | if (isAuthenticated && !authToken) 22 | return ; 23 | 24 | return {children}; 25 | }; 26 | 27 | export default ProtectedRoute; 28 | -------------------------------------------------------------------------------- /server/src/middlewares/verifyToken.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { verify } from "jsonwebtoken"; 3 | import { asyncHandler } from "../utils"; 4 | 5 | type User = { 6 | _id: string; 7 | name: string; 8 | email: string; 9 | }; 10 | 11 | declare global { 12 | namespace Express { 13 | interface Request { 14 | user: User; 15 | } 16 | } 17 | } 18 | 19 | const verifyToken = asyncHandler( 20 | async (req: Request, res: Response, next: NextFunction) => { 21 | let token = req?.headers?.authorization?.split(`"`)[1]; 22 | if (!token) return res.status(401).send({ message: "Unauthorized" }); 23 | let decoded = await verify(token, process.env.JWT_SECRET as string); 24 | req.user = decoded as User; 25 | next(); 26 | } 27 | ); 28 | 29 | export default verifyToken; 30 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Google Sheets 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /server/src/models/cell.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | const CellSchema = new Schema( 4 | { 5 | gridId: { 6 | required: true, 7 | index: true, 8 | type: Schema.Types.ObjectId, 9 | }, 10 | rowId: { 11 | required: true, 12 | type: Number, 13 | }, 14 | columnId: { 15 | required: true, 16 | type: Number, 17 | }, 18 | background: { 19 | default: "#ffffff", 20 | type: String, 21 | }, 22 | textAlign: { 23 | default: "left", 24 | type: String, 25 | }, 26 | text: { 27 | default: "", 28 | type: String, 29 | }, 30 | content: { 31 | default: [], 32 | type: Array, 33 | }, 34 | }, 35 | { timestamps: true } 36 | ); 37 | 38 | const Cell = model("Cell", CellSchema); 39 | 40 | export default Cell; 41 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import UserRoutes from "./user"; 3 | import SheetRoutes from "./sheet"; 4 | import GridRoutes from "./grid"; 5 | import RowRoutes from "./row"; 6 | import ColumnRoutes from "./column"; 7 | import CellRoutes from "./cell"; 8 | 9 | const router = Router(); 10 | 11 | router.use("/api/user", UserRoutes); 12 | router.use("/api/sheet", SheetRoutes); 13 | router.use("/api/grid", GridRoutes); 14 | router.use("/api/row", RowRoutes); 15 | router.use("/api/column", ColumnRoutes); 16 | router.use("/api/cell", CellRoutes); 17 | 18 | router.get("/api/health-check", (req, res) => { 19 | res.status(200).send({ 20 | status: "success", 21 | data: { 22 | message: "Service is running smoothly", 23 | version: "1.0.0", 24 | }, 25 | }); 26 | }); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /server/src/routes/cell.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import CellController from "../controllers/cell"; 3 | import verifyToken from "../middlewares/verifyToken"; 4 | 5 | const router = Router(); 6 | 7 | router.use(verifyToken); 8 | 9 | router.post("/:gridId/create", CellController.createCell); 10 | router.post("/:gridId/duplicate", CellController.duplicateCells); 11 | router.post("/:cellId/copypaste", CellController.copyPasteCell); 12 | 13 | router.put("/:gridId/insert/column", CellController.insertColumn); 14 | router.put("/:gridId/insert/row", CellController.insertRow); 15 | router.put("/:cellId/update", CellController.updateCell); 16 | 17 | router.delete("/:cellId/cell", CellController.removeCell); 18 | router.delete("/:gridId/row", CellController.removeRow); 19 | router.delete("/:gridId/column", CellController.removeColumn); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/HighLightRow.tsx: -------------------------------------------------------------------------------- 1 | type IHighLightRowProps = { 2 | scale: number; 3 | row: IRow; 4 | }; 5 | 6 | const HighLightRow = ({ 7 | scale, 8 | row: { height, rowId, width, x, y }, 9 | }: IHighLightRowProps) => { 10 | let top = `calc(${y}px - var(--row-height))`; 11 | 12 | return ( 13 |
14 |
18 | 22 | {rowId} 23 | 24 |
25 |
26 | ); 27 | }; 28 | 29 | export default HighLightRow; 30 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { HashRouter } from "react-router-dom"; 3 | import { Router } from "@/router"; 4 | import { ToastContainer } from "react-toastify"; 5 | import { AuthProvider } from "@/hooks/useAuth"; 6 | import { ChakraProvider } from "@chakra-ui/react"; 7 | 8 | import "react-toastify/dist/ReactToastify.css"; 9 | import "@/assets/css/index.css"; 10 | 11 | export const App = () => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default App; 32 | -------------------------------------------------------------------------------- /server/src/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | 3 | type CORS = (req: Request, res: Response, next: NextFunction) => void; 4 | 5 | let regex = 6 | /^(?:https?:\/\/(?:vkaswin\.github\.io|localhost:\d+|vercel\.com))$/; 7 | 8 | let allowedHeaders = ["Authorization", "Content-Type"]; 9 | 10 | const cors: CORS = (req, res, next) => { 11 | let origin = req.headers.origin; 12 | let method = req.method; 13 | 14 | if (origin && regex.test(origin)) { 15 | res.setHeader("Access-Control-Allow-Origin", "*"); 16 | res.setHeader("Access-Control-Allow-Methods", "*"); 17 | res.setHeader("Access-Control-Allow-Headers", allowedHeaders.join(", ")); 18 | res.setHeader("Access-Control-Allow-Credentials", "true"); 19 | 20 | return method == "OPTIONS" ? res.status(200).end() : next(); 21 | } 22 | 23 | return !origin ? next() : res.status(403).end(); 24 | }; 25 | 26 | export default cors; 27 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-sheets", 3 | "version": "1.0.0", 4 | "description": "Google Sheets", 5 | "main": "index.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node dist/index.js", 9 | "dev": "nodemon index.ts", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Aswin Kumar", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@types/bcryptjs": "^2.4.2", 16 | "@types/cors": "^2.8.17", 17 | "@types/express": "^4.17.17", 18 | "@types/jsonwebtoken": "^9.0.1", 19 | "@types/node": "^18.13.0", 20 | "nodemon": "^2.0.20", 21 | "ts-node": "^10.9.1", 22 | "typescript": "^4.9.5" 23 | }, 24 | "dependencies": { 25 | "bcryptjs": "^2.4.3", 26 | "dotenv": "^16.0.3", 27 | "express": "^4.18.2", 28 | "jsonwebtoken": "^9.0.0", 29 | "mongoose": "^6.9.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noFallthroughCasesInSwitch": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "types": ["node"], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./src/*"], 25 | }, 26 | }, 27 | "include": ["src", "src/components/Slides/.tsx", "src/components/Slides/.tsx"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } -------------------------------------------------------------------------------- /client/src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | import { UseFormRegisterReturn } from "react-hook-form"; 3 | 4 | import styles from "./Input.module.scss"; 5 | 6 | type InputProps = { 7 | label: string; 8 | errorType?: string; 9 | message?: Record; 10 | register: UseFormRegisterReturn; 11 | } & ComponentProps<"input">; 12 | 13 | const Input = ({ 14 | label, 15 | errorType, 16 | message, 17 | type = "text", 18 | placeholder = "Enter here", 19 | register, 20 | ...rest 21 | }: InputProps) => { 22 | return ( 23 |
24 |
25 | 26 | 27 |
28 | {errorType && ( 29 |
30 | 31 | {message?.[errorType]} 32 |
33 | )} 34 |
35 | ); 36 | }; 37 | 38 | export default Input; 39 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/HighLightColumn.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { convertToTitle } from "@/utils"; 3 | 4 | type IHighLightColumnProps = { 5 | scale: number; 6 | column: IColumn; 7 | }; 8 | 9 | const HighLightColumn = ({ 10 | scale, 11 | column: { columnId, height, width, x, y }, 12 | }: IHighLightColumnProps) => { 13 | let left = `calc(${x}px - var(--col-width))`; 14 | 15 | const title = useMemo(() => { 16 | return convertToTitle(columnId); 17 | }, [columnId]); 18 | 19 | return ( 20 |
21 |
25 | 29 | {title} 30 | 31 |
32 |
33 | ); 34 | }; 35 | 36 | export default HighLightColumn; 37 | -------------------------------------------------------------------------------- /server/src/controllers/row.ts: -------------------------------------------------------------------------------- 1 | import Grid from "../models/grid"; 2 | import Row from "../models/row"; 3 | import { CustomError, asyncHandler } from "../utils"; 4 | 5 | const createRow = asyncHandler(async (req, res) => { 6 | let { gridId } = req.params; 7 | 8 | let grid = await Grid.findById(gridId); 9 | 10 | if (!grid) { 11 | throw new CustomError({ message: "Gird not exist", status: 400 }); 12 | } 13 | 14 | req.body.gridId = gridId; 15 | 16 | let row = await Row.create(req.body); 17 | 18 | res.status(200).send({ 19 | data: { rowId: row._id }, 20 | message: "Row has been created successfully", 21 | }); 22 | }); 23 | 24 | const updateRow = asyncHandler(async (req, res) => { 25 | let { rowId } = req.params; 26 | 27 | let row = await Row.findById(rowId); 28 | 29 | if (!row) { 30 | throw new CustomError({ message: "Row not exist", status: 400 }); 31 | } 32 | 33 | await Row.findByIdAndUpdate(rowId, { $set: req.body }); 34 | 35 | res.status(200).send({ message: "Row has been updated successfully" }); 36 | }); 37 | 38 | const RowController = { createRow, updateRow }; 39 | 40 | export default RowController; 41 | -------------------------------------------------------------------------------- /server/src/controllers/column.ts: -------------------------------------------------------------------------------- 1 | import Column from "../models/column"; 2 | import Grid from "../models/grid"; 3 | import { CustomError, asyncHandler } from "../utils"; 4 | 5 | const createColumn = asyncHandler(async (req, res) => { 6 | let { gridId } = req.params; 7 | 8 | let grid = await Grid.findById(gridId); 9 | 10 | if (!grid) { 11 | throw new CustomError({ message: "Gird not exist", status: 400 }); 12 | } 13 | 14 | req.body.gridId = gridId; 15 | 16 | let row = await Column.create(req.body); 17 | 18 | res.status(200).send({ 19 | data: { 20 | columnId: row._id, 21 | }, 22 | message: "Column has been created successfully", 23 | }); 24 | }); 25 | 26 | const updateColumn = asyncHandler(async (req, res) => { 27 | let { columnId } = req.params; 28 | 29 | let row = await Column.findById(columnId); 30 | 31 | if (!row) { 32 | throw new CustomError({ message: "Column not exist", status: 400 }); 33 | } 34 | 35 | await Column.findByIdAndUpdate(columnId, { $set: req.body }); 36 | 37 | res.status(200).send({ message: "Column has been updated successfully" }); 38 | }); 39 | 40 | const ColumnController = { createColumn, updateColumn }; 41 | 42 | export default ColumnController; 43 | -------------------------------------------------------------------------------- /client/src/pages/SignIn/SignIn.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: #fff; 3 | border: 1px solid #dadce0; 4 | border-radius: 8px; 5 | width: 100%; 6 | max-width: 420px; 7 | padding: 30px; 8 | margin: 30px 0px; 9 | .logo { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | gap: 15px; 14 | span { 15 | color: #202124; 16 | font-size: 24px; 17 | &:last-child { 18 | font-size: 16px; 19 | } 20 | } 21 | } 22 | .form { 23 | display: flex; 24 | flex-direction: column; 25 | gap: 30px; 26 | margin-top: 30px; 27 | input { 28 | height: 46px; 29 | } 30 | .cta { 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | gap: 15px; 35 | margin-top: 20px; 36 | a { 37 | color: #1a73e8; 38 | font-size: 14px; 39 | font-family: "Open-Sans-Medium", sans-serif; 40 | } 41 | button { 42 | background-color: #1a73e8; 43 | color: white; 44 | width: 90px; 45 | height: 36px; 46 | font-family: "Open-Sans-Medium", sans-serif; 47 | border: none; 48 | border-radius: 4px; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/src/components/Pagination/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactPaginate from "react-paginate"; 2 | 3 | import "./Pagination.scss"; 4 | 5 | type PaginationProps = { 6 | pageMeta: IPageMeta; 7 | onPageChange: (page: number) => void; 8 | }; 9 | 10 | const Pagination = ({ 11 | pageMeta: { totalPages, page }, 12 | onPageChange, 13 | }: PaginationProps) => { 14 | return ( 15 |
16 | onPageChange(selected)} 31 | containerClassName="pagination" 32 | pageClassName="inactive-page" 33 | pageLinkClassName="inactive-link" 34 | activeLinkClassName="active-link" 35 | activeClassName="active-page" 36 | /> 37 |
38 | ); 39 | }; 40 | 41 | export default Pagination; 42 | -------------------------------------------------------------------------------- /client/src/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --header-height: 60px; 7 | --toolbar-height: 50px; 8 | --bottom-bar-height: 36px; 9 | --grid-width: 100%; 10 | --grid-height: calc( 11 | 100% - 12 | calc( 13 | var(--header-height) + var(--bottom-bar-height) + var(--toolbar-height) 14 | ) 15 | ); 16 | } 17 | 18 | * { 19 | @apply m-0 p-0 box-border; 20 | } 21 | 22 | html { 23 | @apply h-full; 24 | } 25 | 26 | body { 27 | @apply font-regular h-full; 28 | } 29 | 30 | input[type="number"]::-webkit-outer-spin-button, 31 | input[type="number"]::-webkit-inner-spin-button { 32 | @apply appearance-none m-0; 33 | } 34 | 35 | button:disabled { 36 | @apply opacity-50; 37 | } 38 | 39 | #root { 40 | @apply h-full; 41 | } 42 | 43 | .ql-editor { 44 | @apply w-full h-full outline-none font-regular; 45 | } 46 | 47 | .ql-clipboard { 48 | @apply hidden; 49 | } 50 | 51 | .loader { 52 | @apply flex w-9 h-9 border-[5px] border-light-green border-b-transparent rounded-full animate-spin; 53 | } 54 | 55 | ::-webkit-scrollbar { 56 | width: 7px; 57 | } 58 | ::-webkit-scrollbar-track { 59 | background: white; 60 | } 61 | ::-webkit-scrollbar-thumb { 62 | background: lightgray; 63 | border-radius: 10px; 64 | } 65 | -------------------------------------------------------------------------------- /client/src/services/Sheet.ts: -------------------------------------------------------------------------------- 1 | import axios from "./axios"; 2 | import { SHEET_URL } from "./config"; 3 | 4 | type ISheetData = { 5 | _id: string; 6 | title: string; 7 | grids: ISheetGrid[]; 8 | }; 9 | 10 | export const getSheetById = (sheetId: string) => { 11 | return axios<{ message: string; data: ISheetData }>({ 12 | url: `${SHEET_URL}/${sheetId}/detail`, 13 | method: "get", 14 | }); 15 | }; 16 | 17 | export const updateSheetById = ( 18 | sheetId: string, 19 | data: Partial 20 | ) => { 21 | return axios({ 22 | url: `${SHEET_URL}/${sheetId}/update`, 23 | method: "put", 24 | data, 25 | }); 26 | }; 27 | 28 | export const getSheetList = (params: { 29 | limit: number; 30 | search: string; 31 | page: number; 32 | }) => { 33 | return axios<{ 34 | message: string; 35 | data: { sheets: ISheetList; pageMeta: IPageMeta }; 36 | }>({ 37 | url: `${SHEET_URL}/list`, 38 | method: "get", 39 | params, 40 | }); 41 | }; 42 | 43 | export const removeSheetById = (sheetId: string) => { 44 | return axios<{ message: string }>({ 45 | url: `${SHEET_URL}/${sheetId}/remove`, 46 | method: "delete", 47 | }); 48 | }; 49 | 50 | export const createSheet = () => { 51 | return axios<{ message: string; data: { sheetId: string } }>({ 52 | url: `${SHEET_URL}/create`, 53 | method: "post", 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /client/src/services/Grid.ts: -------------------------------------------------------------------------------- 1 | import axios from "./axios"; 2 | import { GRID_URL } from "./config"; 3 | 4 | export const getGridById = (gridId: string) => { 5 | return axios<{ message: string; data: IGridData }>({ 6 | url: `${GRID_URL}/${gridId}/detail`, 7 | method: "get", 8 | }); 9 | }; 10 | 11 | export const createGrid = (sheetId: string) => { 12 | return axios<{ message: string; data: ISheetGrid }>({ 13 | url: `${GRID_URL}/${sheetId}/create`, 14 | method: "post", 15 | }); 16 | }; 17 | 18 | export const searchGrid = (gridId: string, q: string) => { 19 | return axios<{ message: string; data: { cells: string[] } }>({ 20 | url: `${GRID_URL}/${gridId}/search`, 21 | method: "get", 22 | params: { q }, 23 | }); 24 | }; 25 | 26 | export const removeGridById = (gridId: string) => { 27 | return axios<{ message: string }>({ 28 | url: `${GRID_URL}/${gridId}/remove`, 29 | method: "delete", 30 | }); 31 | }; 32 | 33 | export const updateGridById = (gridId: string, data: Partial) => { 34 | return axios<{ message: string }>({ 35 | url: `${GRID_URL}/${gridId}/update`, 36 | method: "put", 37 | data, 38 | }); 39 | }; 40 | 41 | export const duplicateGridById = (gridId: string) => { 42 | return axios<{ message: string; data: { grid: ISheetGrid } }>({ 43 | url: `${GRID_URL}/${gridId}/duplicate`, 44 | method: "post", 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/EditCell.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import classNames from "classnames"; 3 | import { convertToTitle } from "@/utils"; 4 | 5 | type IEditCellProps = { 6 | cell: ICell | null; 7 | data?: ICellProps; 8 | }; 9 | 10 | const EditCell = ({ cell, data }: IEditCellProps) => { 11 | let { columnId, height, rowId, width, x, y } = cell || {}; 12 | 13 | let { background = "#FFFFFF" } = data || {}; 14 | 15 | const cellId = useMemo(() => { 16 | if (!columnId) return ""; 17 | return `${convertToTitle(columnId)}${rowId}`; 18 | }, [columnId]); 19 | 20 | return ( 21 |
35 |
40 |
41 | {cellId} 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default EditCell; 48 | -------------------------------------------------------------------------------- /server/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request, NextFunction } from "express"; 2 | import { JsonWebTokenError } from "jsonwebtoken"; 3 | import { sign } from "jsonwebtoken"; 4 | 5 | const colors = [ 6 | "#EF4770", 7 | "#6F6F6F", 8 | "#DCB604", 9 | "#199393", 10 | "#029ACD", 11 | "#11C1DA", 12 | "#3B8FFC", 13 | "#18C6A0", 14 | "#B387FF", 15 | "#F75334", 16 | ]; 17 | 18 | export class CustomError extends Error { 19 | status!: number; 20 | 21 | constructor({ message, status }: { message: string; status: number }) { 22 | super(message); 23 | this.status = status; 24 | } 25 | } 26 | 27 | export const asyncHandler = ( 28 | cb: (req: Request, res: Response, next: NextFunction) => T 29 | ) => { 30 | return async (req: Request, res: Response, next: NextFunction) => { 31 | try { 32 | await cb(req, res, next); 33 | } catch (error: any) { 34 | let status = 35 | error?.status || (error instanceof JsonWebTokenError ? 401 : 500); 36 | 37 | let message = error?.message || "Internal Server Error"; 38 | 39 | res.status(status).send({ message }); 40 | 41 | console.log("🚀 ~ file: asyncHandler.ts:19 ~ error:", error); 42 | } 43 | }; 44 | }; 45 | 46 | export const generateJwtToken = (payload: string | object | Buffer) => { 47 | return sign(payload, process.env.JWT_SECRET as string, { 48 | expiresIn: process.env.JWT_EXPIRE, 49 | }); 50 | }; 51 | 52 | export const generateRandomColor = () => { 53 | return colors[Math.floor(Math.random() * colors.length)]; 54 | }; 55 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Header.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { useAuth } from "@/hooks/useAuth"; 4 | import { useSheet } from "@/hooks/useSheet"; 5 | import Avatar from "@/components/Avatar"; 6 | import useTitle from "@/hooks/useTitle"; 7 | import { debounce, getStaticUrl } from "@/utils"; 8 | 9 | const Header = () => { 10 | const { user, logout } = useAuth(); 11 | 12 | const { sheetDetail, handleTitleChange } = useSheet(); 13 | 14 | useTitle(sheetDetail?.title); 15 | 16 | const handleChange = debounce((event: ChangeEvent) => { 17 | handleTitleChange(event.target.innerText); 18 | }, 500); 19 | 20 | return ( 21 |
22 |
23 | 24 | 28 | 29 |
35 |
36 | {user && } 37 |
38 | ); 39 | }; 40 | 41 | export default Header; 42 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/HighLightSearchCells.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useMemo } from "react"; 2 | import classNames from "classnames"; 3 | 4 | type IHighLightSearchCellsProps = { 5 | cells: ICell[]; 6 | highLightCells: string[]; 7 | activeHighLightIndex: number; 8 | getCellById: (cellId: string) => ICellDetail | undefined; 9 | }; 10 | 11 | const HighLightSearchCells = ({ 12 | cells, 13 | highLightCells, 14 | activeHighLightIndex, 15 | getCellById, 16 | }: IHighLightSearchCellsProps) => { 17 | const cellIds = useMemo(() => { 18 | return new Set(highLightCells); 19 | }, [highLightCells]); 20 | 21 | return ( 22 | 23 | {cells.map(({ cellId, height, width, x, y }) => { 24 | let cellData = getCellById(cellId); 25 | 26 | if (!cellData || !cellIds.has(cellData._id)) return null; 27 | 28 | let left = `calc(${x}px - var(--col-width))`; 29 | let top = `calc(${y}px - var(--row-height))`; 30 | 31 | return ( 32 |
47 | ); 48 | })} 49 |
50 | ); 51 | }; 52 | 53 | export default HighLightSearchCells; 54 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: { 6 | borderColor: { 7 | gray: "#C4C7C5", 8 | "dark-gray": "#747775", 9 | blue: "#1973E8", 10 | "dark-blue": "#0b57d0", 11 | "light-gray": "#D5D5D5", 12 | "mild-gray": "#d9d9d9", 13 | "light-silver": "#e1e3e1", 14 | "light-green": "#4eac6e", 15 | }, 16 | outlineColor: { 17 | "light-blue": "#a8c7fa", 18 | "dark-blue": "#0b57d0", 19 | "dark-gray": "#747775", 20 | }, 21 | backgroundColor: { 22 | "mild-gray": "#E1E1E1", 23 | "dark-silver": "#C0C0C0", 24 | "dark-blue": "#0b57d0", 25 | "light-blue": "#D3E3FD", 26 | blue: "#4589eb", 27 | "light-green": "#C7EED1", 28 | "dark-green": "#79D28F", 29 | "sky-blue": "#e1e9f7", 30 | "light-sky-blue": "rgb(14, 101, 235, 0.1)", 31 | "mild-blue": "#EDF2FA", 32 | "light-silver": "#f9fbfd", 33 | "light-gray": "#BEC1C6", 34 | }, 35 | textColor: { 36 | "light-gray": "#575a5a", 37 | "dark-gray": "#444746", 38 | "mild-black": "#202124", 39 | "light-green": "#4eac6e", 40 | "dark-blue": "#0b57d0", 41 | }, 42 | fontFamily: { 43 | medium: "Open Sans Medium", 44 | bold: "Open Sans Bold", 45 | semibold: "Open Sans SemiBold", 46 | light: "Open Sans Light", 47 | regular: "Open Sans", 48 | }, 49 | }, 50 | }, 51 | plugins: [], 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const cookie = { 2 | set: ({ 3 | name, 4 | value, 5 | days, 6 | }: { 7 | name: string; 8 | value: T; 9 | days: number; 10 | }): void => { 11 | let expireDate = new Date(); 12 | expireDate.setTime(expireDate.getTime() + days * 24 * 60 * 60 * 1000); 13 | let expires = "; expires=" + expireDate.toUTCString(); 14 | document.cookie = name + "=" + JSON.stringify(value) + expires + "; path=/"; 15 | }, 16 | 17 | get: (name: string): string | null => { 18 | let match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)")); 19 | return match ? match[2] : null; 20 | }, 21 | 22 | remove: (name: string): void => { 23 | document.cookie = 24 | name + "=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; 25 | }, 26 | }; 27 | 28 | export const debounce = ( 29 | fn: (args: T) => void, 30 | delay: number 31 | ): ((args: T) => void) => { 32 | let timeoutId: any; 33 | return (...args) => { 34 | if (timeoutId) clearTimeout(timeoutId); 35 | timeoutId = setTimeout(() => { 36 | fn(...args); 37 | }, delay); 38 | }; 39 | }; 40 | 41 | export const getStaticUrl = (path: string) => { 42 | return `${ 43 | process.env.NODE_ENV === "production" ? "/google-sheets" : "" 44 | }${path}`; 45 | }; 46 | 47 | export const convertToTitle = (n: number) => { 48 | if (n < 27) return String.fromCharCode(n + 64); 49 | 50 | let s = ""; 51 | 52 | while (n > 0) { 53 | let temp = n % 26; 54 | temp = temp == 0 ? 26 : temp; 55 | s = String.fromCharCode(temp + 64) + s; 56 | n -= temp; 57 | n /= 26; 58 | } 59 | 60 | return s; 61 | }; 62 | -------------------------------------------------------------------------------- /client/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const config: IConfig = { 2 | lineWidth: 2, 3 | strokeStyle: "#C4C7C5", 4 | cellHeight: 25, 5 | cellWidth: 100, 6 | colWidth: 46, 7 | rowHeight: 25, 8 | scrollBarSize: 12, 9 | scrollThumbSize: 48, 10 | customFonts: [ 11 | "open-sans", 12 | "barlow-condensed", 13 | "caveat", 14 | "crimson-text", 15 | "dancing-script", 16 | "inter", 17 | "lato", 18 | "lobster", 19 | "montserrat", 20 | "nunito-sans", 21 | "oswald", 22 | "pacifico", 23 | "poppins", 24 | "quicksand", 25 | "roboto", 26 | "roboto-mono", 27 | "rubik", 28 | "ubuntu", 29 | ], 30 | fonts: { 31 | "open-sans": "Open Sans", 32 | "barlow-condensed": "Barlow Condensed", 33 | caveat: "Caveat", 34 | "crimson-text": "Crimson Text", 35 | "dancing-script": "Dancing Script", 36 | inter: "Inter", 37 | lato: "Lato", 38 | lobster: "Lobster", 39 | montserrat: "Montserrat", 40 | "nunito-sans": "Nunito Sans", 41 | oswald: "Oswald", 42 | pacifico: "Pacifico", 43 | poppins: "Poppins", 44 | quicksand: "Quicksand", 45 | roboto: "Roboto", 46 | "roboto-mono": "Roboto Mono", 47 | rubik: "Rubik", 48 | ubuntu: "Ubuntu", 49 | }, 50 | scale: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], 51 | fontSizes: [ 52 | "6px", 53 | "8px", 54 | "10", 55 | "12px", 56 | "14px", 57 | "16px", 58 | "18px", 59 | "20px", 60 | "22px", 61 | "24px", 62 | "26px", 63 | "28px", 64 | "30px", 65 | "32px", 66 | "34px", 67 | "36px", 68 | ], 69 | defaultFont: "open-sans", 70 | defaultFontSize: "16px", 71 | }; 72 | -------------------------------------------------------------------------------- /client/src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from "react"; 2 | import { Navigate, RouteObject, useRoutes } from "react-router-dom"; 3 | import ProtectedRoute from "./ProtectedRoute"; 4 | 5 | let AuthLayout = lazy(() => import(`@/layouts/AuthLayout`)); 6 | let SignIn = lazy(() => import(`@/pages/SignIn`)); 7 | let SignUp = lazy(() => import(`@/pages/SignUp`)); 8 | 9 | let SheetsDetail = lazy(() => import(`@/pages/SheetDetail`)); 10 | let SheetsList = lazy(() => import(`@/pages/SheetList`)); 11 | 12 | let PageNotFound = lazy(() => import(`@/pages/NotFound`)); 13 | 14 | let routes: RouteObject[] = [ 15 | { 16 | path: "", 17 | element: , 18 | }, 19 | { 20 | path: "auth", 21 | element: , 22 | children: [ 23 | { 24 | path: "sign-in", 25 | element: ( 26 | } 28 | isAuthenticated={false} 29 | redirectIfLogin 30 | /> 31 | ), 32 | }, 33 | { 34 | path: "sign-up", 35 | element: ( 36 | } 38 | isAuthenticated={false} 39 | redirectIfLogin 40 | /> 41 | ), 42 | }, 43 | ], 44 | }, 45 | { 46 | path: "sheet/list", 47 | element: } />, 48 | }, 49 | { 50 | path: "sheet/:sheetId", 51 | element: } />, 52 | }, 53 | { path: "*", element: }, 54 | ]; 55 | 56 | export const Router = () => { 57 | return useRoutes(routes); 58 | }; 59 | 60 | export default Router; 61 | -------------------------------------------------------------------------------- /server/src/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import User from "../models/user"; 3 | import { asyncHandler, CustomError, generateJwtToken } from "../utils"; 4 | 5 | const signUp = asyncHandler(async (req, res) => { 6 | let { email, name, password } = req.body; 7 | 8 | let isExist = await User.findOne({ email }); 9 | 10 | if (isExist) 11 | throw new CustomError({ message: "User already exists", status: 400 }); 12 | 13 | let salt = await bcrypt.genSalt(); 14 | let hashPassword = await bcrypt.hash(password, salt); 15 | 16 | let user = await User.create({ 17 | name, 18 | email, 19 | password: hashPassword, 20 | }); 21 | 22 | let token = generateJwtToken({ 23 | _id: user._id, 24 | name: user.name, 25 | email: user.email, 26 | colorCode: user.colorCode, 27 | }); 28 | 29 | res.status(200).send({ 30 | data: { token }, 31 | message: "Success", 32 | }); 33 | }); 34 | 35 | const signIn = asyncHandler(async (req, res) => { 36 | let { email, password } = req.body; 37 | 38 | let user = await User.findOne({ email }); 39 | 40 | if (!user) throw new CustomError({ message: "User not exist", status: 400 }); 41 | 42 | let isPasswordMatched = await bcrypt.compare(password, user.password); 43 | 44 | if (!isPasswordMatched) 45 | throw new CustomError({ message: "Wrong Password", status: 400 }); 46 | 47 | let token = generateJwtToken({ 48 | _id: user._id, 49 | name: user.name, 50 | email: user.email, 51 | colorCode: user.colorCode, 52 | }); 53 | 54 | res.status(200).send({ 55 | data: { token }, 56 | message: "Success", 57 | }); 58 | }); 59 | 60 | const UserController = { signIn, signUp }; 61 | 62 | export default UserController; 63 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-sheets", 3 | "homepage": "https://vkaswin.github.io/google-sheets", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview", 12 | "predeploy": "npm run build", 13 | "deploy": "gh-pages -d dist" 14 | }, 15 | "dependencies": { 16 | "@chakra-ui/react": "^2.8.2", 17 | "axios": "^1.4.0", 18 | "classnames": "^2.3.2", 19 | "dayjs": "^1.11.10", 20 | "gh-pages": "^5.0.0", 21 | "jwt-decode": "^3.1.2", 22 | "quill": "^1.3.7", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-hook-form": "^7.45.1", 26 | "react-paginate": "^8.2.0", 27 | "react-popper": "^2.3.0", 28 | "react-router-dom": "^6.14.1", 29 | "react-toastify": "^9.1.3" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^20.4.0", 33 | "@types/quill": "^2.0.14", 34 | "@types/react": "^18.0.37", 35 | "@types/react-dom": "^18.0.11", 36 | "@typescript-eslint/eslint-plugin": "^5.59.0", 37 | "@typescript-eslint/parser": "^5.59.0", 38 | "@vitejs/plugin-react": "^4.0.0", 39 | "autoprefixer": "^10.4.14", 40 | "eslint": "^8.38.0", 41 | "eslint-plugin-react-hooks": "^4.6.0", 42 | "eslint-plugin-react-refresh": "^0.3.4", 43 | "postcss": "^8.4.27", 44 | "tailwindcss": "^3.3.3", 45 | "typescript": "^5.0.2", 46 | "vite": "^4.3.9" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/HighLightCell.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | type IHighlightCellProps = { 4 | cell: ICell; 5 | dashed?: boolean; 6 | }; 7 | 8 | const HighLightCell = ({ cell, dashed = false }: IHighlightCellProps) => { 9 | return ( 10 |
11 |
21 |
31 |
41 |
51 |
52 | ); 53 | }; 54 | 55 | export default HighLightCell; 56 | -------------------------------------------------------------------------------- /client/src/components/Input/Input.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 5px; 5 | .input_field:has(input:focus, input:not(:placeholder-shown)), 6 | .input_field[aria-invalid="true"] { 7 | label { 8 | top: 0px; 9 | left: 7px; 10 | transform: translateY(-10px); 11 | color: #1a73e8; 12 | font-size: 12px; 13 | background-color: white; 14 | } 15 | input { 16 | outline: 2px solid #1a73e8; 17 | } 18 | } 19 | .input_field[aria-invalid="true"] { 20 | label { 21 | color: #d93025 !important; 22 | } 23 | input { 24 | outline: 2px solid #d50000 !important; 25 | } 26 | } 27 | .input_field { 28 | position: relative; 29 | label { 30 | position: absolute; 31 | padding: 0px 5px; 32 | transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); 33 | pointer-events: none; 34 | top: 50%; 35 | left: 10px; 36 | transform: translateY(-50%); 37 | background-color: transparent; 38 | color: #5f6368; 39 | font-size: 14px; 40 | } 41 | input { 42 | background-color: #fff; 43 | outline: 1px solid #dadce0; 44 | border: 1px solid transparent; 45 | border-radius: 4px; 46 | width: 100%; 47 | height: 36px; 48 | padding: 0px 10px; 49 | transition: outline 0.15s cubic-bezier(0.4, 0, 0.2, 1); 50 | &::placeholder { 51 | color: transparent; 52 | } 53 | } 54 | input[type="password"] { 55 | padding: 5px 35px 5px 10px; 56 | } 57 | .icon { 58 | position: absolute; 59 | top: 50%; 60 | right: 10px; 61 | transform: translateY(-50%); 62 | i { 63 | color: #606368; 64 | font-size: 20px; 65 | cursor: pointer; 66 | } 67 | } 68 | } 69 | .error_msg { 70 | display: flex; 71 | gap: 5px; 72 | color: #d93025; 73 | i { 74 | font-size: 16px; 75 | margin-top: 2px; 76 | } 77 | span { 78 | font-size: 14px; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/pages/SignIn/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { useForm } from "react-hook-form"; 4 | import { useAuth } from "@/hooks/useAuth"; 5 | import Input from "@/components/Input"; 6 | 7 | import styles from "./SignIn.module.scss"; 8 | 9 | const SignIn = () => { 10 | let { 11 | formState: { errors }, 12 | register, 13 | handleSubmit, 14 | } = useForm(); 15 | 16 | let { signIn } = useAuth(); 17 | 18 | useEffect(() => { 19 | window.addEventListener("keydown", handleKeyDown); 20 | return () => { 21 | window.removeEventListener("keydown", handleKeyDown); 22 | }; 23 | }, []); 24 | 25 | let handleKeyDown = (event: KeyboardEvent) => { 26 | if (event.key === "Enter") handleSubmit(signIn)(); 27 | }; 28 | 29 | return ( 30 |
31 |
32 |
33 | Sign In 34 | to continue 35 |
36 |
37 | 49 | 60 |
61 | Create Account 62 | 63 |
64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export default SignIn; 71 | -------------------------------------------------------------------------------- /client/src/services/Cell.ts: -------------------------------------------------------------------------------- 1 | import axios from "./axios"; 2 | import { CELL_URL } from "./config"; 3 | 4 | export const createCell = (gridId: string, data: Partial) => { 5 | return axios<{ message: string; data: { cellId: string } }>({ 6 | url: `${CELL_URL}/${gridId}/create`, 7 | method: "post", 8 | data, 9 | }); 10 | }; 11 | 12 | export const updateCellById = (cellId: string, data: Partial) => { 13 | return axios<{ message: string }>({ 14 | url: `${CELL_URL}/${cellId}/update`, 15 | method: "put", 16 | data, 17 | }); 18 | }; 19 | 20 | export const duplicateCells = (gridId: string, data: IAutoFillData) => { 21 | return axios<{ message: string; data: { cells: ICellDetail[] } }>({ 22 | url: `${CELL_URL}/${gridId}/duplicate`, 23 | method: "post", 24 | data, 25 | }); 26 | }; 27 | 28 | export const copyPasteCell = ( 29 | cellId: string, 30 | data: { rowId: number; columnId: number } 31 | ) => { 32 | return axios<{ message: string; data: { cell: ICellDetail } }>({ 33 | url: `${CELL_URL}/${cellId}/copypaste`, 34 | method: "post", 35 | data, 36 | }); 37 | }; 38 | 39 | export const insertColumn = ( 40 | gridId: string, 41 | data: { direction: IDirection; columnId: number } 42 | ) => { 43 | return axios({ 44 | url: `${CELL_URL}/${gridId}/insert/column`, 45 | method: "put", 46 | data, 47 | }); 48 | }; 49 | 50 | export const insertRow = ( 51 | gridId: string, 52 | data: { direction: IDirection; rowId: number } 53 | ) => { 54 | return axios({ 55 | url: `${CELL_URL}/${gridId}/insert/row`, 56 | method: "put", 57 | data, 58 | }); 59 | }; 60 | 61 | export const deleteCellById = (cellId: string) => { 62 | return axios<{ message: string }>({ 63 | url: `${CELL_URL}/${cellId}/cell`, 64 | method: "delete", 65 | }); 66 | }; 67 | 68 | export const deleteColumn = (gridId: string, columnId: number) => { 69 | return axios({ 70 | url: `${CELL_URL}/${gridId}/column`, 71 | method: "delete", 72 | data: { columnId }, 73 | }); 74 | }; 75 | 76 | export const deleteRow = (gridId: string, rowId: number) => { 77 | return axios({ 78 | url: `${CELL_URL}/${gridId}/row`, 79 | method: "delete", 80 | data: { rowId }, 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /client/src/pages/SignUp/SignUp.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-template-columns: 0.6fr 0.4fr; 4 | gap: 15px; 5 | background-color: #fff; 6 | border: 1px solid #dadce0; 7 | border-radius: 8px; 8 | width: 100%; 9 | max-width: 768px; 10 | padding: 30px; 11 | margin: 30px 0px; 12 | .logo { 13 | display: flex; 14 | flex-direction: column; 15 | gap: 15px; 16 | span { 17 | color: #202124; 18 | font-size: 24px; 19 | } 20 | } 21 | .form { 22 | display: flex; 23 | flex-direction: column; 24 | gap: 30px; 25 | margin-top: 30px; 26 | .wrap_field { 27 | display: grid; 28 | grid-template-columns: repeat(2, 1fr); 29 | gap: 15px; 30 | } 31 | .password_field { 32 | display: flex; 33 | flex-direction: column; 34 | gap: 10px; 35 | .password_note { 36 | color: #5f6368; 37 | font-size: 14px; 38 | } 39 | .show_field { 40 | display: flex; 41 | align-items: center; 42 | gap: 10px; 43 | margin-top: 10px; 44 | input { 45 | accent-color: #1a73e8; 46 | width: 15px; 47 | height: 15px; 48 | cursor: pointer; 49 | } 50 | label { 51 | color: #202124; 52 | font-size: 14px; 53 | cursor: pointer; 54 | } 55 | } 56 | } 57 | .cta { 58 | display: flex; 59 | justify-content: space-between; 60 | align-items: center; 61 | gap: 15px; 62 | margin-top: 40px; 63 | a { 64 | color: #1a73e8; 65 | font-size: 14px; 66 | font-family: "Open-Sans-Medium", sans-serif; 67 | } 68 | button { 69 | background-color: #1a73e8; 70 | color: white; 71 | width: 150px; 72 | height: 36px; 73 | font-family: "Open-Sans-Medium", sans-serif; 74 | border: none; 75 | border-radius: 4px; 76 | } 77 | } 78 | } 79 | .poster { 80 | display: flex; 81 | flex-direction: column; 82 | justify-content: center; 83 | gap: 15px; 84 | img { 85 | display: block; 86 | margin: 0px auto; 87 | max-width: 100%; 88 | height: auto; 89 | object-fit: contain; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /client/src/components/Pagination/Pagination.scss: -------------------------------------------------------------------------------- 1 | .pagination { 2 | display: flex; 3 | justify-content: center; 4 | margin: 15px 0px 0px 0px !important; 5 | flex-wrap: wrap !important; 6 | li { 7 | list-style: none; 8 | } 9 | } 10 | 11 | .inactive-page { 12 | background-color: #ffffff; 13 | border: 1px solid #c7c7c7; 14 | padding: 5px 10px; 15 | margin: 10px 2px; 16 | border-radius: 4px; 17 | } 18 | .inactive-link { 19 | color: #898989; 20 | outline: none; 21 | cursor: pointer; 22 | &:hover { 23 | color: #898989; 24 | text-decoration: none; 25 | } 26 | } 27 | 28 | .active-page { 29 | background-color: #34a853; 30 | border: 1px solid #34a853; 31 | padding: 5px 10px; 32 | margin: 10px 2px; 33 | border-radius: 4px; 34 | } 35 | .active-link { 36 | color: white; 37 | outline: none; 38 | cursor: pointer; 39 | &:hover { 40 | color: white; 41 | text-decoration: none; 42 | } 43 | } 44 | 45 | .previous-page { 46 | background-color: #f5f5f5; 47 | border: 1px solid #c7c7c7; 48 | padding: 5px 10px 5px 10px; 49 | margin: 10px 2px; 50 | border-radius: 4px; 51 | } 52 | .previous-link { 53 | color: #898989; 54 | outline: none; 55 | cursor: pointer; 56 | &:hover { 57 | color: #898989; 58 | text-decoration: none; 59 | } 60 | } 61 | .disable { 62 | background-color: white !important; 63 | } 64 | .break { 65 | background-color: #f5f5f5; 66 | border: 1px solid #c7c7c7; 67 | padding: 5px 10px 5px 10px; 68 | margin: 10px 2px; 69 | border-radius: 4px; 70 | } 71 | .break-link { 72 | color: #898989; 73 | outline: none; 74 | cursor: pointer; 75 | &:hover { 76 | color: #898989; 77 | text-decoration: none; 78 | } 79 | } 80 | 81 | @media only screen and (max-width: 600px) { 82 | .inactive-page { 83 | padding: 2px 5px; 84 | margin: 5px 2px; 85 | font-size: 12px; 86 | } 87 | .active-page { 88 | padding: 2px 5px; 89 | font-size: 12px; 90 | margin: 5px 2px; 91 | } 92 | .previous-page { 93 | padding: 2px 5px; 94 | font-size: 12px; 95 | margin: 5px 2px; 96 | } 97 | .break { 98 | padding: 2px 5px; 99 | font-size: 12px; 100 | margin: 5px 2px; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /client/src/assets/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Open Sans"; 3 | src: url("../fonts/open-sans/OpenSans-Regular.ttf"); 4 | font-display: swap; 5 | } 6 | 7 | @font-face { 8 | font-family: "Open Sans Light"; 9 | src: url("../fonts/open-sans/OpenSans-Light.ttf"); 10 | font-display: swap; 11 | } 12 | 13 | @font-face { 14 | font-family: "Open Sans SemiBold"; 15 | src: url("../fonts/open-sans/OpenSans-SemiBold.ttf"); 16 | font-display: swap; 17 | } 18 | 19 | @font-face { 20 | font-family: "Open Sans Medium"; 21 | src: url("../fonts/open-sans/OpenSans-Medium.ttf"); 22 | font-display: swap; 23 | } 24 | 25 | @font-face { 26 | font-family: "Open Sans Bold"; 27 | src: url("../fonts/open-sans/OpenSans-Bold.ttf"); 28 | font-display: swap; 29 | } 30 | 31 | .ql-font-open-sans { 32 | font-family: "Open Sans", sans-serif; 33 | } 34 | 35 | .ql-font-barlow-condensed { 36 | font-family: "Barlow Condensed", sans-serif; 37 | } 38 | 39 | .ql-font-caveat { 40 | font-family: "Caveat", cursive; 41 | } 42 | .ql-font-crimson-text { 43 | font-family: "Crimson Text", serif; 44 | } 45 | .ql-font-inter { 46 | font-family: "Inter", sans-serif; 47 | } 48 | .ql-font-lato { 49 | font-family: "Lato", sans-serif; 50 | } 51 | 52 | .ql-font-lobster { 53 | font-family: "Lobster", sans-serif; 54 | } 55 | 56 | .ql-font-dancing-script { 57 | font-family: "Dancing Script", cursive; 58 | } 59 | 60 | .ql-font-montserrat { 61 | font-family: "Montserrat", sans-serif; 62 | } 63 | 64 | .ql-font-nunito-sans { 65 | font-family: "Nunito Sans", sans-serif; 66 | } 67 | 68 | .ql-font-oswald { 69 | font-family: "Oswald", sans-serif; 70 | } 71 | 72 | .ql-font-oxygen { 73 | font-family: "Oxygen", sans-serif; 74 | } 75 | 76 | .ql-font-pacifico { 77 | font-family: "Pacifico", cursive; 78 | } 79 | 80 | .ql-font-poppins { 81 | font-family: "Poppins", sans-serif; 82 | } 83 | 84 | .ql-font-quicksand { 85 | font-family: "Quicksand", sans-serif; 86 | } 87 | 88 | .ql-font-roboto { 89 | font-family: "Roboto", sans-serif; 90 | } 91 | 92 | .ql-font-roboto-mono { 93 | font-family: "Roboto Mono", monospace; 94 | } 95 | 96 | .ql-font-rubik { 97 | font-family: "Rubik", sans-serif; 98 | } 99 | 100 | .ql-font-ubuntu { 101 | font-family: "Ubuntu", sans-serif; 102 | } 103 | -------------------------------------------------------------------------------- /client/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useContext, 4 | ReactNode, 5 | useEffect, 6 | useState, 7 | Dispatch, 8 | SetStateAction, 9 | } from "react"; 10 | import { useNavigate } from "react-router-dom"; 11 | import jwtDecode from "jwt-decode"; 12 | import { toast } from "react-toastify"; 13 | import { signInUser, signUpUser } from "@/services/User"; 14 | import { cookie } from "@/utils"; 15 | 16 | type AuthProviderProps = { 17 | children: ReactNode; 18 | }; 19 | 20 | type IAuthContext = { 21 | user?: IUser; 22 | setUser: Dispatch>; 23 | signIn: (data: ISignIn) => Promise; 24 | signUp: (data: ISignUp) => Promise; 25 | logout: () => void; 26 | }; 27 | 28 | const AuthContext = createContext({} as IAuthContext); 29 | 30 | export const AuthProvider = ({ children }: AuthProviderProps) => { 31 | let [user, setUser] = useState(); 32 | 33 | let navigate = useNavigate(); 34 | 35 | useEffect(() => { 36 | let authToken = cookie.get("auth_token"); 37 | authToken && setUser(jwtDecode(authToken)); 38 | document.addEventListener("unauthorized", logout); 39 | return () => { 40 | document.removeEventListener("unauthorized", logout); 41 | }; 42 | }, []); 43 | 44 | let handleAuthResponse = (token: string) => { 45 | cookie.set({ name: "auth_token", value: token, days: 14 }); 46 | setUser(jwtDecode(token)); 47 | navigate("/sheet/list"); 48 | }; 49 | 50 | let signIn = async (data: ISignIn) => { 51 | try { 52 | let { 53 | data: { 54 | data: { token }, 55 | }, 56 | } = await signInUser(data); 57 | handleAuthResponse(token); 58 | } catch (error: any) { 59 | toast.error(error?.message); 60 | if (error?.message === "User not exist") navigate("/auth/sign-up"); 61 | } 62 | }; 63 | 64 | let signUp = async (data: ISignUp) => { 65 | try { 66 | let { 67 | data: { 68 | data: { token }, 69 | }, 70 | } = await signUpUser(data); 71 | handleAuthResponse(token); 72 | } catch (error: any) { 73 | toast.error(error?.message); 74 | } 75 | }; 76 | 77 | let logout = () => { 78 | cookie.remove("auth_token"); 79 | navigate("/"); 80 | setUser(undefined); 81 | }; 82 | 83 | let context: IAuthContext = { 84 | user, 85 | setUser, 86 | signIn, 87 | signUp, 88 | logout, 89 | }; 90 | 91 | return ( 92 | {children} 93 | ); 94 | }; 95 | 96 | export const useAuth = () => { 97 | return useContext(AuthContext); 98 | }; 99 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/ScrollBar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | forwardRef, 3 | useState, 4 | PointerEvent, 5 | MutableRefObject, 6 | useRef, 7 | } from "react"; 8 | import classNames from "classnames"; 9 | 10 | type IScrollBarProps = { axis: "x" | "y"; onScroll: (delta: number) => void }; 11 | 12 | const ScrollBar = forwardRef( 13 | ({ axis, onScroll }, ref) => { 14 | const [pointerId, setPointerId] = useState(null); 15 | 16 | const scrollPosition = useRef({ 17 | curr: { x: 0, y: 0 }, 18 | prev: { x: 0, y: 0 }, 19 | }); 20 | 21 | const isVertical = axis === "y"; 22 | 23 | const handlePointerDown = (event: PointerEvent) => { 24 | let { target, pointerId, pageX, pageY } = event; 25 | (target as HTMLElement).setPointerCapture(pointerId); 26 | scrollPosition.current.curr.x = pageX; 27 | scrollPosition.current.curr.y = pageY; 28 | setPointerId(pointerId); 29 | }; 30 | 31 | const handlePointerMove = (event: PointerEvent) => { 32 | if (!pointerId) return; 33 | 34 | let { pageX, pageY } = event; 35 | 36 | scrollPosition.current.prev.x = scrollPosition.current.curr.x; 37 | scrollPosition.current.prev.y = scrollPosition.current.curr.y; 38 | 39 | scrollPosition.current.curr.x = pageX; 40 | scrollPosition.current.curr.y = pageY; 41 | 42 | let delta; 43 | 44 | if (isVertical) 45 | delta = scrollPosition.current.curr.y - scrollPosition.current.prev.y; 46 | else 47 | delta = scrollPosition.current.curr.x - scrollPosition.current.prev.x; 48 | 49 | onScroll(delta); 50 | }; 51 | 52 | const handlePointerUp = () => { 53 | if (!pointerId) return; 54 | let ele = ref as MutableRefObject; 55 | if (ele) ele.current.releasePointerCapture(pointerId); 56 | setPointerId(null); 57 | }; 58 | 59 | return ( 60 |
68 |
81 |
82 | ); 83 | } 84 | ); 85 | 86 | export default ScrollBar; 87 | -------------------------------------------------------------------------------- /client/src/types/Sheets.ts: -------------------------------------------------------------------------------- 1 | type IRow = { 2 | rowId: number; 3 | } & IRect; 4 | 5 | type IColumn = { 6 | columnId: number; 7 | } & IRect; 8 | 9 | type ICell = { 10 | cellId: string; 11 | rowId: number; 12 | columnId: number; 13 | } & IRect; 14 | 15 | type IColumnDetail = { _id: string; columnId: number; width: number }; 16 | 17 | type IRowDetail = { _id: string; rowId: number; height: number }; 18 | 19 | type ICellDetail = ICellProps & Pick; 20 | 21 | type ICellProps = { 22 | _id: string; 23 | text?: string; 24 | content?: any[]; 25 | background?: string; 26 | }; 27 | 28 | type IRowProps = { 29 | height?: number; 30 | backgroundColor?: string; 31 | }; 32 | 33 | type IColumnProps = { 34 | width?: number; 35 | backgroundColor?: string; 36 | }; 37 | 38 | type IRenderGrid = (data: { 39 | rowStart: number; 40 | colStart: number; 41 | offsetX?: number; 42 | offsetY?: number; 43 | }) => void; 44 | 45 | type IRect = { 46 | x: number; 47 | y: number; 48 | width: number; 49 | height: number; 50 | }; 51 | 52 | type IPaintCell = (ctx: CanvasRenderingContext2D, cell: ICell) => void; 53 | 54 | type IPaintCells = (ctx: CanvasRenderingContext2D, cells: ICell[]) => void; 55 | 56 | type IPaintRect = ( 57 | ctx: CanvasRenderingContext2D, 58 | backgroundColor: string, 59 | rect: IRect 60 | ) => void; 61 | 62 | type IPaintCellLine = (ctx: CanvasRenderingContext2D, rect: IRect) => void; 63 | 64 | type IPaintCellContent = ( 65 | ctx: CanvasRenderingContext2D, 66 | content: any[], 67 | 68 | rect: IRect 69 | ) => void; 70 | 71 | type IPickerOptions = "background" | "color"; 72 | 73 | type IFormatTypes = 74 | | "bold" 75 | | "italic" 76 | | "strike" 77 | | "underline" 78 | | "align" 79 | | "direction" 80 | | "font" 81 | | "size" 82 | | "textAlign" 83 | | IPickerOptions; 84 | 85 | type IFormatText = (type: IFormatTypes, value: string | boolean) => void; 86 | 87 | type IActiveStyle = { 88 | bold: boolean; 89 | strike: boolean; 90 | italic: boolean; 91 | font: string; 92 | underline: boolean; 93 | background: string; 94 | color: string; 95 | size: string; 96 | }; 97 | 98 | type IDirection = "top" | "bottom" | "left" | "right"; 99 | 100 | type IGrid = { rows: IRow[]; columns: IColumn[]; cells: ICell[] }; 101 | 102 | type IConfig = { 103 | lineWidth: number; 104 | strokeStyle: string; 105 | cellHeight: number; 106 | cellWidth: number; 107 | colWidth: number; 108 | defaultFont: string; 109 | defaultFontSize: string; 110 | scrollBarSize: number; 111 | scrollThumbSize: number; 112 | rowHeight: number; 113 | customFonts: string[]; 114 | fonts: Record; 115 | scale: number[]; 116 | fontSizes: string[]; 117 | }; 118 | 119 | type ISheetGrid = { 120 | _id: string; 121 | title: string; 122 | color: string; 123 | sheetId: string; 124 | }; 125 | 126 | type ISheetDetail = { 127 | _id: string; 128 | title: string; 129 | grids: ISheetGrid[]; 130 | }; 131 | 132 | type ISheetList = { 133 | _id: string; 134 | title: string; 135 | createdAt: string; 136 | updatedAt: string; 137 | lastOpenedAt: string; 138 | }[]; 139 | 140 | type IAutoFillDetail = { 141 | srcCellId: string; 142 | destCellId?: string; 143 | rect: { 144 | width: number; 145 | height: number; 146 | translateX: number; 147 | translateY: number; 148 | }; 149 | }; 150 | 151 | type IPageMeta = { 152 | page: number; 153 | total: number; 154 | totalPages: number; 155 | }; 156 | 157 | type IAutoFillData = { 158 | createCells: { rowId: number; columnId: number }[]; 159 | updateCells: string[]; 160 | cellId: string; 161 | }; 162 | 163 | type IGridData = { 164 | grid: ISheetGrid; 165 | rows: IRowDetail[]; 166 | columns: IColumnDetail[]; 167 | cells: ICellDetail[]; 168 | }; 169 | -------------------------------------------------------------------------------- /server/src/controllers/sheet.ts: -------------------------------------------------------------------------------- 1 | import Sheet from "../models/sheet"; 2 | import Grid from "../models/grid"; 3 | import Cell from "../models/cell"; 4 | import Row from "../models/row"; 5 | import Column from "../models/column"; 6 | import { asyncHandler, CustomError } from "../utils"; 7 | 8 | const createSheet = asyncHandler(async (req, res) => { 9 | let sheet = await Sheet.create({ 10 | createdBy: req.user._id, 11 | }); 12 | 13 | let grid = await Grid.create({ sheetId: sheet._id, createdBy: req.user._id }); 14 | 15 | await Sheet.findByIdAndUpdate(sheet._id, { $push: { grids: grid._id } }); 16 | 17 | res.status(200).send({ 18 | data: { sheetId: sheet._id }, 19 | message: "Sheet has been created successfully", 20 | }); 21 | }); 22 | 23 | const getSheetById = asyncHandler(async (req, res) => { 24 | let { sheetId } = req.params; 25 | 26 | let sheet = await Sheet.findById(sheetId, { 27 | grids: 1, 28 | title: 1, 29 | createdBy: 1, 30 | }).populate({ 31 | path: "grids", 32 | select: { title: 1, color: 1, sheetId: 1 }, 33 | }); 34 | 35 | if (!sheet) { 36 | throw new CustomError({ message: "Sheet not exist", status: 400 }); 37 | } 38 | 39 | if (sheet.createdBy.toString() !== req.user._id) { 40 | throw new CustomError({ 41 | message: "You don't have access to view and edit the sheet", 42 | status: 400, 43 | }); 44 | } 45 | 46 | await Sheet.findByIdAndUpdate(sheetId, { 47 | $set: { lastOpenedAt: new Date().toISOString() }, 48 | }); 49 | 50 | res.status(200).send({ 51 | data: { 52 | _id: sheet._id, 53 | title: sheet.title, 54 | grids: sheet.grids, 55 | }, 56 | message: "Success", 57 | }); 58 | }); 59 | 60 | const updateSheetById = asyncHandler(async (req, res) => { 61 | let { sheetId } = req.params; 62 | 63 | let sheet = await Sheet.findById(sheetId); 64 | 65 | if (!sheet) { 66 | throw new CustomError({ status: 400, message: "Sheet not exist" }); 67 | } 68 | 69 | await Sheet.findByIdAndUpdate(sheetId, { $set: req.body }); 70 | 71 | res.status(200).send({ message: "Sheet has been updated successfully" }); 72 | }); 73 | 74 | const getSheetList = asyncHandler(async (req, res) => { 75 | let { page = 1, search = "", limit = 20 } = req.query; 76 | let { _id: userId } = req.user; 77 | 78 | const matchQuery = { 79 | createdBy: userId, 80 | title: { $regex: search, $options: "i" }, 81 | }; 82 | 83 | let sheets = await Sheet.find( 84 | matchQuery, 85 | { createdBy: 0 }, 86 | { 87 | sort: { 88 | createdAt: 1, 89 | }, 90 | limit: +limit, 91 | skip: (+page - 1) * +limit, 92 | } 93 | ); 94 | 95 | let count = (await Sheet.find(matchQuery)).length; 96 | 97 | let pageMeta = { 98 | totalPages: Math.ceil(count / +limit), 99 | total: count, 100 | page: +page, 101 | }; 102 | 103 | res.status(200).send({ data: { sheets, pageMeta }, message: "Success" }); 104 | }); 105 | 106 | const removeSheetById = asyncHandler(async (req, res) => { 107 | let { sheetId } = req.params; 108 | 109 | let sheet = await Sheet.findById(sheetId); 110 | 111 | if (!sheet) { 112 | throw new CustomError({ message: "Sheet not exist", status: 400 }); 113 | } 114 | 115 | let query = { gridId: { $in: sheet.grids } }; 116 | 117 | await Cell.deleteMany(query); 118 | 119 | await Row.deleteMany(query); 120 | 121 | await Column.deleteMany(query); 122 | 123 | await Grid.deleteMany({ _id: { $in: sheet.grids } }); 124 | 125 | await Sheet.findByIdAndDelete(sheetId); 126 | 127 | res.status(200).send({ message: "Sheet has been deleted successfully" }); 128 | }); 129 | 130 | const SheetController = { 131 | createSheet, 132 | getSheetById, 133 | getSheetList, 134 | updateSheetById, 135 | removeSheetById, 136 | }; 137 | 138 | export default SheetController; 139 | -------------------------------------------------------------------------------- /client/src/components/Sheet/BottomBar/index.tsx: -------------------------------------------------------------------------------- 1 | import { WheelEvent, useRef, useEffect, useLayoutEffect } from "react"; 2 | import { useLocation, Link } from "react-router-dom"; 3 | import { useSheet } from "@/hooks/useSheet"; 4 | import classNames from "classnames"; 5 | import { 6 | Menu, 7 | MenuButton, 8 | MenuItem, 9 | MenuList, 10 | Portal, 11 | Tooltip, 12 | } from "@chakra-ui/react"; 13 | import GridCard from "./GridCard"; 14 | 15 | const BottomBar = () => { 16 | const scrollContainerRef = useRef(null); 17 | 18 | const { 19 | sheetDetail, 20 | handleCreateGrid, 21 | handleDeleteGrid, 22 | handleUpdateGrid, 23 | handleDuplicateGrid, 24 | } = useSheet(); 25 | 26 | const location = useLocation(); 27 | 28 | const searchParams = new URLSearchParams(location.search); 29 | 30 | const gridId = searchParams.get("gridId"); 31 | 32 | useLayoutEffect(() => { 33 | const element = document.querySelector(`[data-gridid="${gridId}"]`); 34 | if (!element) return; 35 | element.scrollIntoView({ behavior: "smooth", inline: "nearest" }); 36 | }, [gridId]); 37 | 38 | const handleScroll = (event: WheelEvent) => { 39 | if (!scrollContainerRef.current) return; 40 | 41 | scrollContainerRef.current.scrollBy({ 42 | behavior: "smooth", 43 | left: event.deltaY, 44 | }); 45 | }; 46 | 47 | let { grids = [] } = sheetDetail || {}; 48 | 49 | return ( 50 |
51 |
52 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | {grids.map(({ _id, title, color = "transperant" }) => { 69 | let isActive = _id === gridId; 70 | return ( 71 | 72 | 73 | 79 | 83 | {title} 84 | 85 | 86 | ); 87 | })} 88 | 89 | 90 | 91 |
92 |
97 | {grids.map((grid, index) => { 98 | return ( 99 | handleDeleteGrid(index, grid._id)} 104 | onUpdateGrid={(data) => handleUpdateGrid(index, grid._id, data)} 105 | onDuplicateGrid={() => handleDuplicateGrid(grid._id)} 106 | /> 107 | ); 108 | })} 109 |
110 |
111 | ); 112 | }; 113 | 114 | export default BottomBar; 115 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/RowResizer.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, PointerEvent, useRef, useState, MouseEvent } from "react"; 2 | 3 | type IRowResizerProps = { 4 | rows: IRow[]; 5 | onClick: (rowId: number) => void; 6 | onResize: (rowId: number, height: number) => void; 7 | }; 8 | 9 | const RowResizer = ({ rows, onClick, onResize }: IRowResizerProps) => { 10 | const [selectedRow, setSelectedRow] = useState(null); 11 | 12 | const [showLine, setShowLine] = useState(false); 13 | 14 | const resizeRef = useRef(null); 15 | 16 | const pointerRef = useRef(null); 17 | 18 | const columnRef = useRef(null); 19 | 20 | const handlePointerDown = (event: PointerEvent) => { 21 | if (!resizeRef.current || !selectedRow) return; 22 | 23 | pointerRef.current = event.pageY; 24 | resizeRef.current.setPointerCapture(event.pointerId); 25 | resizeRef.current.addEventListener("pointermove", handlePointerMove); 26 | resizeRef.current.addEventListener("pointerup", handlePointerUp, { 27 | once: true, 28 | }); 29 | setShowLine(true); 30 | }; 31 | 32 | const handlePointerMove = (event: any) => { 33 | if (!resizeRef.current || !pointerRef.current || !selectedRow) return; 34 | let { pageY } = event as PointerEvent; 35 | let height = selectedRow.height + -(pointerRef.current - pageY); 36 | if (height <= 25) return; 37 | setSelectedRow({ ...selectedRow, height }); 38 | }; 39 | 40 | const handlePointerUp = (event: any) => { 41 | if (!resizeRef.current || !selectedRow || !pointerRef.current) return; 42 | 43 | let { pageY } = event as PointerEvent; 44 | resizeRef.current.removeEventListener("pointermove", handlePointerMove); 45 | let height = selectedRow.height + -(pointerRef.current - pageY); 46 | onResize(selectedRow.rowId, Math.max(25, height)); 47 | pointerRef.current = null; 48 | setSelectedRow(null); 49 | setShowLine(false); 50 | }; 51 | 52 | const findRowByYAxis = (y: number) => { 53 | let left = 0; 54 | let right = rows.length - 1; 55 | let rowId: number | null = null; 56 | 57 | while (left <= right) { 58 | let mid = Math.floor((left + right) / 2); 59 | if (rows[mid].y <= y) { 60 | left = mid + 1; 61 | rowId = mid; 62 | } else { 63 | right = mid - 1; 64 | } 65 | } 66 | 67 | if (rowId === null) return null; 68 | 69 | return rowId; 70 | }; 71 | 72 | const handleMouseMove = (event: MouseEvent) => { 73 | if (!columnRef.current || showLine) return; 74 | let { top } = columnRef.current.getBoundingClientRect(); 75 | let rowId = findRowByYAxis(event.pageY - top); 76 | if (rowId === null || selectedRow?.rowId === rowId) return; 77 | setSelectedRow({ ...rows[rowId] }); 78 | }; 79 | 80 | const handleMouseLeave = () => { 81 | setSelectedRow(null); 82 | }; 83 | 84 | const handleClick = () => { 85 | if (!selectedRow) return; 86 | onClick(selectedRow.rowId); 87 | }; 88 | 89 | return ( 90 | 91 |
97 | {selectedRow && ( 98 |
109 |
114 |
115 |
116 |
117 |
118 | )} 119 |
120 | {selectedRow && showLine && ( 121 |
128 | )} 129 |
130 | ); 131 | }; 132 | 133 | export default RowResizer; 134 | -------------------------------------------------------------------------------- /client/src/assets/fonts/open-sans/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /client/src/components/Sheet/BottomBar/GridCard.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useRef, useState } from "react"; 2 | import { 3 | Box, 4 | Menu, 5 | MenuButton, 6 | MenuItem, 7 | MenuList, 8 | Popover, 9 | PopoverContent, 10 | PopoverTrigger, 11 | Portal, 12 | } from "@chakra-ui/react"; 13 | import { Link } from "react-router-dom"; 14 | import classNames from "classnames"; 15 | import ColorPicker from "@/components/Sheet/Grid/ColorPicker"; 16 | 17 | type IGridCardProps = { 18 | grid: ISheetGrid; 19 | gridId: string | null; 20 | onDeleteGrid: () => void; 21 | onUpdateGrid: (data: Partial) => void; 22 | onDuplicateGrid: () => void; 23 | }; 24 | 25 | const GridCard = ({ 26 | grid: { _id, color, title }, 27 | gridId, 28 | onDeleteGrid, 29 | onUpdateGrid, 30 | onDuplicateGrid, 31 | }: IGridCardProps) => { 32 | const [isEdit, setIsEdit] = useState(false); 33 | 34 | const editorRef = useRef(null); 35 | 36 | useEffect(() => { 37 | if (!isEdit) return; 38 | focusEditor(); 39 | }, [isEdit]); 40 | 41 | const focusEditor = () => { 42 | const selection = getSelection(); 43 | if (!selection || !editorRef.current) return; 44 | const range = document.createRange(); 45 | selection.removeAllRanges(); 46 | range.selectNodeContents(editorRef.current); 47 | range.collapse(false); 48 | selection.addRange(range); 49 | editorRef.current.focus(); 50 | }; 51 | 52 | const handleBlur = () => { 53 | if (!editorRef.current) return; 54 | onUpdateGrid({ title: editorRef.current.innerText }); 55 | setIsEdit(false); 56 | }; 57 | 58 | let isActive = gridId === _id; 59 | 60 | return ( 61 | 72 | {isActive && ( 73 | 77 | )} 78 | {isEdit ? ( 79 |
86 | ) : ( 87 | {title} 88 | )} 89 | {!isEdit && isActive ? ( 90 | 91 | {({ isOpen }) => { 92 | return ( 93 | 94 | e.stopPropagation()}> 95 | 96 | 97 | 98 | 99 | onDeleteGrid()}>Delete 100 | Duplicate 101 | setIsEdit(true)}>Rename 102 | 103 | 104 | 105 | Change color 106 | 107 | 108 | 109 | 110 | 112 | onUpdateGrid({ 113 | color, 114 | }) 115 | } 116 | /> 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ); 126 | }} 127 | 128 | ) : ( 129 | 134 | )} 135 | 136 | ); 137 | }; 138 | 139 | export default GridCard; 140 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/ColumnResizer.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, PointerEvent, useRef, useState, MouseEvent } from "react"; 2 | 3 | type IColumnResizerProps = { 4 | columns: IColumn[]; 5 | onClick: (columnId: number) => void; 6 | onResize: (columnId: number, width: number) => void; 7 | }; 8 | 9 | const CoumnResizer = ({ columns, onResize, onClick }: IColumnResizerProps) => { 10 | const [selectedColumn, setSelectedColumn] = useState(null); 11 | 12 | const [showLine, setShowLine] = useState(false); 13 | 14 | const [showResizer, setShowResizer] = useState(false); 15 | 16 | const resizeRef = useRef(null); 17 | 18 | const pointerRef = useRef(null); 19 | 20 | const handlePointerDown = (event: PointerEvent) => { 21 | if (!resizeRef.current || !selectedColumn) return; 22 | 23 | pointerRef.current = event.pageX; 24 | resizeRef.current.setPointerCapture(event.pointerId); 25 | resizeRef.current.addEventListener("pointermove", handlePointerMove); 26 | resizeRef.current.addEventListener("pointerup", handlePointerUp, { 27 | once: true, 28 | }); 29 | setShowLine(true); 30 | }; 31 | 32 | const handlePointerMove = (event: any) => { 33 | if (!resizeRef.current || !pointerRef.current || !selectedColumn) return; 34 | let { pageX } = event as PointerEvent; 35 | let width = selectedColumn.width + -(pointerRef.current - pageX); 36 | if (width < 50) return; 37 | setSelectedColumn({ ...selectedColumn, width }); 38 | }; 39 | 40 | const handlePointerUp = (event: any) => { 41 | if (!resizeRef.current || !selectedColumn || !pointerRef.current) return; 42 | 43 | let { pageX } = event as PointerEvent; 44 | resizeRef.current.removeEventListener("pointermove", handlePointerMove); 45 | 46 | let width = selectedColumn.width + -(pointerRef.current - pageX); 47 | onResize(selectedColumn.columnId, Math.max(50, width)); 48 | pointerRef.current = null; 49 | setSelectedColumn(null); 50 | setShowLine(false); 51 | }; 52 | 53 | const toggleResizer = () => { 54 | setShowResizer(!showResizer); 55 | }; 56 | 57 | const findColumnByXAxis = (x: number) => { 58 | let left = 0; 59 | let right = columns.length - 1; 60 | let columnId: number | null = null; 61 | 62 | while (left <= right) { 63 | let mid = Math.floor((left + right) / 2); 64 | if (columns[mid].x <= x) { 65 | left = mid + 1; 66 | columnId = mid; 67 | } else { 68 | right = mid - 1; 69 | } 70 | } 71 | 72 | if (columnId === null) return null; 73 | 74 | return columnId; 75 | }; 76 | 77 | const handleMouseMove = (event: MouseEvent) => { 78 | if (showLine) return; 79 | let columnId = findColumnByXAxis(event.pageX); 80 | if (columnId === null || selectedColumn?.columnId === columnId) return; 81 | setSelectedColumn({ ...columns[columnId] }); 82 | }; 83 | 84 | const handleMouseLeave = () => { 85 | setSelectedColumn(null); 86 | }; 87 | 88 | const handleClick = () => { 89 | if (!selectedColumn) return; 90 | onClick(selectedColumn.columnId); 91 | }; 92 | 93 | return ( 94 | 95 |
100 | {selectedColumn && ( 101 |
112 |
116 |
121 |
122 |
123 |
124 |
125 | )} 126 |
127 | {selectedColumn && showLine && ( 128 |
135 | )} 136 |
137 | ); 138 | }; 139 | 140 | export default CoumnResizer; 141 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { usePopper } from "react-popper"; 3 | import { VirtualElement } from "@popperjs/core"; 4 | 5 | type IContextMenuProps = { 6 | rect: Pick; 7 | isRowSelected: boolean; 8 | isColumnSelected: boolean; 9 | isCellSelected: boolean; 10 | onCut: () => void; 11 | onCopy: () => void; 12 | onPaste: () => void; 13 | onDeleteRow: () => void; 14 | onDeleteColumn: () => void; 15 | onDeleteCell: () => void; 16 | onInsertColumn: (direction: IDirection) => void; 17 | onInsertRow: (direction: IDirection) => void; 18 | }; 19 | 20 | const ContextMenu = ({ 21 | rect, 22 | onCopy, 23 | onPaste, 24 | onDeleteCell, 25 | onDeleteRow, 26 | onDeleteColumn, 27 | onInsertRow, 28 | onInsertColumn, 29 | }: IContextMenuProps) => { 30 | const [popperElement, setPopperElement] = useState( 31 | null 32 | ); 33 | 34 | const virtualReference = useMemo(() => { 35 | return { 36 | getBoundingClientRect: () => { 37 | return { 38 | width: 0, 39 | height: 0, 40 | right: 0, 41 | bottom: 0, 42 | left: rect.x, 43 | top: rect.y, 44 | }; 45 | }, 46 | } as VirtualElement; 47 | }, [rect]); 48 | 49 | const { attributes, styles } = usePopper(virtualReference, popperElement, { 50 | placement: "right", 51 | }); 52 | 53 | const Divider = () => ( 54 |
55 | ); 56 | 57 | return ( 58 |
64 |
65 | {/* */} 75 | 85 | 95 | 96 | 103 | 110 | 111 | 118 | 125 | 126 | 133 | 134 | 141 | 142 | 149 |
150 |
151 | ); 152 | }; 153 | 154 | export default ContextMenu; 155 | -------------------------------------------------------------------------------- /client/src/pages/SignUp/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { useForm } from "react-hook-form"; 4 | import { useAuth } from "@/hooks/useAuth"; 5 | import Input from "@/components/Input"; 6 | import { getStaticUrl } from "@/utils"; 7 | 8 | import styles from "./SignUp.module.scss"; 9 | 10 | type FormData = { 11 | firstName: string; 12 | lastName: string; 13 | email: string; 14 | password: string; 15 | confirmPassword: string; 16 | }; 17 | 18 | const SignUp = () => { 19 | let { 20 | formState: { errors }, 21 | register, 22 | getValues, 23 | handleSubmit, 24 | } = useForm(); 25 | 26 | let { signUp } = useAuth(); 27 | 28 | let [showPassword, setShowPassword] = useState(false); 29 | 30 | useEffect(() => { 31 | window.addEventListener("keydown", handleKeyDown); 32 | return () => { 33 | window.removeEventListener("keydown", handleKeyDown); 34 | }; 35 | }, []); 36 | 37 | let handleKeyDown = (event: KeyboardEvent) => { 38 | if (event.key === "Enter") handleSubmit(onSubmit)(); 39 | }; 40 | 41 | let onSubmit = ({ firstName, lastName, email, password }: FormData) => { 42 | let body = { 43 | name: `${firstName} ${lastName}`, 44 | email, 45 | password, 46 | }; 47 | signUp(body); 48 | }; 49 | 50 | let handleCheckBox = () => { 51 | setShowPassword(!showPassword); 52 | }; 53 | 54 | return ( 55 |
56 |
57 |
58 | Create your Account 59 |
60 |
61 |
62 | 76 | 90 |
91 | 103 |
104 |
105 | 118 | value === getValues("password"), 129 | })} 130 | /> 131 |
132 | 133 | Use 8 or more characters with a mix of letters, numbers, uppercase 134 | & symbols 135 | 136 |
137 | 143 | 144 |
145 |
146 |
147 | Sign in instead 148 | 149 |
150 |
151 |
152 |
153 | 154 |
155 |
156 | ); 157 | }; 158 | 159 | export default SignUp; 160 | -------------------------------------------------------------------------------- /client/public/account.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/src/controllers/grid.ts: -------------------------------------------------------------------------------- 1 | import { Types } from "mongoose"; 2 | import Cell from "../models/cell"; 3 | import Column from "../models/column"; 4 | import Grid from "../models/grid"; 5 | import Row from "../models/row"; 6 | import Sheet from "../models/sheet"; 7 | import { CustomError, asyncHandler } from "../utils"; 8 | 9 | const createGrid = asyncHandler(async (req, res) => { 10 | let { sheetId } = req.params; 11 | 12 | let sheet = await Sheet.findById(sheetId); 13 | 14 | if (!sheet) { 15 | throw new CustomError({ message: "Sheet not exist", status: 400 }); 16 | } 17 | 18 | let grid = await Grid.create({ 19 | sheetId, 20 | title: `Sheet ${sheet.grids.length + 1}`, 21 | createdBy: req.user._id, 22 | }); 23 | 24 | await Sheet.findByIdAndUpdate(sheetId, { $push: { grids: grid._id } }); 25 | 26 | res.status(200).send({ 27 | data: { 28 | _id: grid._id, 29 | title: grid.title, 30 | sheetId: grid.sheetId, 31 | color: grid.color, 32 | }, 33 | message: "Grid has been created successfully", 34 | }); 35 | }); 36 | 37 | const getGridById = asyncHandler(async (req, res) => { 38 | let { gridId } = req.params; 39 | 40 | let grid = await Grid.findById(gridId, { title: 1, sheetId: 1, color: 1 }); 41 | 42 | if (!grid) { 43 | throw new CustomError({ message: "Grid not exist", status: 400 }); 44 | } 45 | 46 | let rows = await Row.find({ gridId }, { rowId: 1, height: 1 }); 47 | 48 | let columns = await Column.find({ gridId }, { columnId: 1, width: 1 }); 49 | 50 | let cells = await Cell.find( 51 | { gridId }, 52 | { createdAt: 0, updatedAt: 0, __v: 0 } 53 | ); 54 | 55 | res.status(200).send({ 56 | data: { 57 | grid: { 58 | _id: grid._id, 59 | color: grid.color, 60 | title: grid.title, 61 | sheetId: grid.sheetId, 62 | }, 63 | rows, 64 | columns, 65 | cells, 66 | }, 67 | message: "Success", 68 | }); 69 | }); 70 | 71 | const searchGrid = asyncHandler(async (req, res) => { 72 | let { gridId } = req.params; 73 | let { q } = req.query; 74 | 75 | q = q?.toString().trim(); 76 | 77 | if (!q || !q.length) { 78 | res.status(200).send({ data: { cells: [] }, message: "Success" }); 79 | return; 80 | } 81 | 82 | let grid = await Grid.findById(gridId); 83 | 84 | if (!grid) { 85 | throw new CustomError({ message: "Grid not exist", status: 400 }); 86 | } 87 | 88 | let cells = await Cell.aggregate([ 89 | { 90 | $match: { 91 | gridId: new Types.ObjectId(gridId), 92 | text: { $regex: q, $options: "i" }, 93 | }, 94 | }, 95 | { 96 | $sort: { 97 | rowId: 1, 98 | columnId: 1, 99 | }, 100 | }, 101 | { 102 | $project: { 103 | _id: 1, 104 | }, 105 | }, 106 | ]); 107 | 108 | let cellIds = cells.map((cell) => cell._id); 109 | 110 | res.status(200).send({ 111 | data: { cells: cellIds }, 112 | message: "Success", 113 | }); 114 | }); 115 | 116 | const removeGridById = asyncHandler(async (req, res) => { 117 | let { gridId } = req.params; 118 | 119 | let grid = await Grid.findById(gridId); 120 | 121 | if (!grid) { 122 | throw new CustomError({ message: "Grid not exist", status: 400 }); 123 | } 124 | 125 | let sheet = await Sheet.findById(grid.sheetId); 126 | 127 | if (!sheet) { 128 | throw new CustomError({ message: "Sheet not exist", status: 400 }); 129 | } 130 | 131 | await Cell.deleteMany({ gridId }); 132 | 133 | await Row.deleteMany({ gridId }); 134 | 135 | await Column.deleteMany({ gridId }); 136 | 137 | await Grid.findByIdAndDelete(gridId); 138 | 139 | let isRemoveSheet = sheet.grids.length === 1; 140 | 141 | if (isRemoveSheet) { 142 | await Sheet.findByIdAndDelete(grid.sheetId); 143 | } else { 144 | await Sheet.findByIdAndUpdate(grid.sheetId, { $pull: { grids: gridId } }); 145 | } 146 | 147 | res.status(200).send({ 148 | message: `${ 149 | isRemoveSheet ? "Sheet" : "Grid" 150 | } has been deleted successfully`, 151 | }); 152 | }); 153 | 154 | const updateGridById = asyncHandler(async (req, res) => { 155 | let { gridId } = req.params; 156 | 157 | let grid = await Grid.findById(gridId, { title: 1, sheetId: 1, color: 1 }); 158 | 159 | if (!grid) { 160 | throw new CustomError({ message: "Grid not exist", status: 400 }); 161 | } 162 | 163 | await Grid.findByIdAndUpdate(gridId, { $set: req.body }); 164 | 165 | res.status(200).send({ message: "Grid has been updated successfully" }); 166 | }); 167 | 168 | const duplicateGridById = asyncHandler(async (req, res) => { 169 | let { gridId } = req.params; 170 | 171 | let grid = await Grid.findById(gridId, { title: 1, sheetId: 1, color: 1 }); 172 | 173 | if (!grid) { 174 | throw new CustomError({ message: "Grid not exist", status: 400 }); 175 | } 176 | 177 | let newGrid = await Grid.create({ 178 | color: grid.color, 179 | sheetId: grid.sheetId, 180 | title: `Copy of ${grid.title}`, 181 | createdBy: req.user._id, 182 | }); 183 | 184 | await Sheet.findByIdAndUpdate(grid.sheetId, { 185 | $push: { grids: newGrid._id }, 186 | }); 187 | 188 | let cells = await Cell.aggregate([ 189 | { 190 | $match: { gridId: new Types.ObjectId(gridId) }, 191 | }, 192 | { 193 | $project: { 194 | _id: 0, 195 | gridId: newGrid._id, 196 | rowId: 1, 197 | columnId: 1, 198 | text: 1, 199 | content: 1, 200 | background: 1, 201 | }, 202 | }, 203 | ]); 204 | 205 | let rows = await Row.aggregate([ 206 | { $match: { gridId: new Types.ObjectId(gridId) } }, 207 | { 208 | $project: { 209 | _id: 0, 210 | gridId: newGrid._id, 211 | rowId: 1, 212 | height: 1, 213 | }, 214 | }, 215 | ]); 216 | 217 | let columns = await Column.aggregate([ 218 | { $match: { gridId: new Types.ObjectId(gridId) } }, 219 | { 220 | $project: { 221 | _id: 0, 222 | gridId: newGrid._id, 223 | columnId: 1, 224 | width: 1, 225 | }, 226 | }, 227 | ]); 228 | 229 | if (cells.length) { 230 | await Cell.create(cells); 231 | } 232 | 233 | if (rows.length) { 234 | await Row.create(rows); 235 | } 236 | 237 | if (columns.length) { 238 | await Column.create(columns); 239 | } 240 | 241 | res.status(200).send({ 242 | message: "Grid has been duplicated successfully", 243 | data: { grid: newGrid.toObject() }, 244 | }); 245 | }); 246 | 247 | const GridController = { 248 | createGrid, 249 | getGridById, 250 | searchGrid, 251 | removeGridById, 252 | updateGridById, 253 | duplicateGridById, 254 | }; 255 | 256 | export default GridController; 257 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/AutoFill.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, PointerEvent, MutableRefObject } from "react"; 2 | 3 | type IAutoFillProps = { 4 | cells: ICell[]; 5 | gridRef: MutableRefObject; 6 | selectedCell: ICell; 7 | onAutoFillCell: (data: IAutoFillData) => void; 8 | getCellById: (cellId?: string) => ICellDetail | undefined; 9 | getCellIdByCoordiantes: (x: number, y: number) => string | null; 10 | }; 11 | 12 | const AutoFill = ({ 13 | gridRef, 14 | cells, 15 | selectedCell, 16 | getCellById, 17 | onAutoFillCell, 18 | getCellIdByCoordiantes, 19 | }: IAutoFillProps) => { 20 | const [pointerId, setPointerId] = useState(null); 21 | 22 | const [autoFillDetail, setAutoFillDetail] = useState( 23 | null 24 | ); 25 | 26 | const autoFillRef = useRef(null); 27 | 28 | const handlePointerDown = ({ 29 | nativeEvent: { pointerId }, 30 | }: PointerEvent) => { 31 | if (!autoFillRef.current) return; 32 | autoFillRef.current.setPointerCapture(pointerId); 33 | setPointerId(pointerId); 34 | setAutoFillDetail({ 35 | srcCellId: selectedCell.cellId, 36 | rect: { 37 | width: selectedCell.width, 38 | height: selectedCell.height, 39 | translateX: 0, 40 | translateY: 0, 41 | }, 42 | }); 43 | }; 44 | 45 | const handleAutoFillCell = () => { 46 | if ( 47 | !autoFillDetail || 48 | !autoFillDetail.destCellId || 49 | selectedCell.cellId === autoFillDetail.destCellId 50 | ) 51 | return; 52 | 53 | let { translateX, translateY } = autoFillDetail.rect; 54 | 55 | let src = autoFillDetail.srcCellId.split(",").map((id) => +id); 56 | let dest = autoFillDetail.destCellId.split(",").map((id) => +id); 57 | 58 | let createCells: { rowId: number; columnId: number }[] = []; 59 | let updateCells: string[] = []; 60 | let rowStart, rowEnd, colStart, colEnd; 61 | 62 | if (translateX >= 0 && translateY >= 0) { 63 | rowStart = src[1]; 64 | rowEnd = dest[1]; 65 | colStart = src[0]; 66 | colEnd = dest[0]; 67 | } else if (translateX < 0 && translateY < 0) { 68 | colStart = dest[0]; 69 | colEnd = src[0]; 70 | rowStart = dest[1]; 71 | rowEnd = src[1]; 72 | } else if (translateX < 0 && translateY === 0) { 73 | rowStart = src[1]; 74 | rowEnd = dest[1]; 75 | colStart = dest[0]; 76 | colEnd = src[0]; 77 | } else if (translateX === 0 && translateY < 0) { 78 | colStart = src[0]; 79 | colEnd = dest[0]; 80 | rowStart = dest[1]; 81 | rowEnd = src[1]; 82 | } 83 | 84 | if ( 85 | typeof colStart !== "number" || 86 | typeof colEnd !== "number" || 87 | typeof rowStart !== "number" || 88 | typeof rowEnd !== "number" 89 | ) 90 | return; 91 | 92 | for (let columnId = colStart; columnId <= colEnd; columnId++) { 93 | for (let rowId = rowStart; rowId <= rowEnd; rowId++) { 94 | let cellId = `${columnId},${rowId}`; 95 | if (cellId === selectedCell.cellId) continue; 96 | let cellData = getCellById(cellId); 97 | if (cellData) updateCells.push(cellData._id); 98 | else createCells.push({ rowId, columnId }); 99 | } 100 | } 101 | 102 | if (!updateCells.length && !createCells.length) return; 103 | 104 | let cellData = getCellById(autoFillDetail.srcCellId); 105 | 106 | if (!cellData) return; 107 | 108 | onAutoFillCell({ 109 | updateCells, 110 | createCells, 111 | cellId: cellData._id, 112 | }); 113 | }; 114 | 115 | const handlePointerUp = () => { 116 | if (!autoFillRef.current || !pointerId) return; 117 | autoFillRef.current.releasePointerCapture(pointerId); 118 | handleAutoFillCell(); 119 | setPointerId(null); 120 | setAutoFillDetail(null); 121 | }; 122 | 123 | const handlePointerMove = (event: PointerEvent) => { 124 | if (!pointerId || !gridRef.current || !autoFillDetail) return; 125 | 126 | let { left, top } = gridRef.current.getBoundingClientRect(); 127 | 128 | let pageX = event.pageX - left; 129 | let pageY = event.pageY - top; 130 | 131 | let cellId = getCellIdByCoordiantes(pageX, pageY); 132 | 133 | if (!cellId) return; 134 | 135 | let cellData = cells.find((cell) => cell.cellId === cellId); 136 | 137 | if (!cellData) return; 138 | 139 | let x = pageX - selectedCell.x; 140 | let y = pageY - selectedCell.y; 141 | 142 | let width = 0; 143 | let height = 0; 144 | let translateX = 0; 145 | let translateY = 0; 146 | 147 | if (x < 0) translateX = -(selectedCell.x - cellData.x); 148 | 149 | if (y < 0) translateY = -(selectedCell.y - cellData.y); 150 | 151 | if (cellId !== selectedCell.cellId) { 152 | if (cellData.x > selectedCell.x) 153 | width = cellData.x + cellData.width - selectedCell.x; 154 | else width = selectedCell.x + selectedCell.width - cellData.x; 155 | 156 | if (cellData.y > selectedCell.y) 157 | height = cellData.y + cellData.height - selectedCell.y; 158 | else height = selectedCell.y + selectedCell.height - cellData.y; 159 | } else { 160 | width = selectedCell.width; 161 | height = selectedCell.height; 162 | } 163 | 164 | setAutoFillDetail({ 165 | ...autoFillDetail, 166 | destCellId: cellId, 167 | rect: { 168 | width, 169 | height, 170 | translateX, 171 | translateY, 172 | }, 173 | }); 174 | }; 175 | 176 | return ( 177 |
178 | e.stopPropagation()} 190 | onPointerDown={handlePointerDown} 191 | onPointerMove={handlePointerMove} 192 | onPointerUp={handlePointerUp} 193 | > 194 | {autoFillDetail && ( 195 |
206 | )} 207 |
208 | ); 209 | }; 210 | 211 | export default AutoFill; 212 | -------------------------------------------------------------------------------- /server/src/controllers/cell.ts: -------------------------------------------------------------------------------- 1 | import Cell from "../models/cell"; 2 | import Grid from "../models/grid"; 3 | import Row from "../models/row"; 4 | import { CustomError, asyncHandler } from "../utils"; 5 | 6 | const createCell = asyncHandler(async (req, res) => { 7 | let { gridId } = req.params; 8 | 9 | let grid = await Grid.findById(gridId); 10 | 11 | if (!grid) { 12 | throw new CustomError({ message: "Grid not exist", status: 400 }); 13 | } 14 | 15 | req.body.gridId = gridId; 16 | 17 | let cell = await Cell.create(req.body); 18 | 19 | res.status(200).send({ data: { cellId: cell._id }, message: "Success" }); 20 | }); 21 | 22 | const updateCell = asyncHandler(async (req, res) => { 23 | let { cellId } = req.params; 24 | 25 | let cell = await Cell.findById(cellId); 26 | 27 | if (!cell) { 28 | throw new CustomError({ message: "Cell not exist", status: 400 }); 29 | } 30 | 31 | await Cell.findByIdAndUpdate(cellId, { $set: req.body }); 32 | 33 | res.status(200).send({ message: "Cell has been updated successfully" }); 34 | }); 35 | 36 | const duplicateCells = asyncHandler(async (req, res) => { 37 | let { gridId } = req.params; 38 | let { createCells, updateCells, cellId } = req.body; 39 | 40 | if ( 41 | !cellId || 42 | !Array.isArray(createCells) || 43 | !Array.isArray(updateCells) || 44 | (!createCells.length && !updateCells.length) 45 | ) 46 | return res.status(200).send({ data: { cells: [], message: "Success" } }); 47 | 48 | let grid = await Grid.findById(gridId); 49 | 50 | if (!grid) { 51 | throw new CustomError({ message: "Grid not exist", status: 400 }); 52 | } 53 | 54 | let cell = await Cell.findById(cellId, { 55 | _id: 0, 56 | __v: 0, 57 | rowId: 0, 58 | columnId: 0, 59 | updatedAt: 0, 60 | createdAt: 0, 61 | }); 62 | 63 | if (!cell) { 64 | throw new CustomError({ message: "Cell not exist", status: 400 }); 65 | } 66 | 67 | let cellDetail = cell.toObject(); 68 | 69 | let body = createCells.map(({ rowId, columnId }) => { 70 | return { ...cellDetail, rowId, columnId }; 71 | }); 72 | 73 | let cells = await Cell.create(body); 74 | 75 | await Cell.updateMany({ _id: { $in: updateCells } }, { $set: cell }); 76 | 77 | cells = cells.concat( 78 | updateCells.map((cellId) => { 79 | return { ...cellDetail, _id: cellId }; 80 | }) as any 81 | ); 82 | 83 | res.status(200).send({ data: { cells }, message: "Success" }); 84 | }); 85 | 86 | const removeCell = asyncHandler(async (req, res) => { 87 | let { cellId } = req.params; 88 | 89 | let cell = await Cell.findById(cellId); 90 | 91 | if (!cell) { 92 | throw new CustomError({ message: "Cell not exist", status: 400 }); 93 | } 94 | 95 | await Cell.findByIdAndDelete(cellId); 96 | 97 | res.status(200).send({ message: "Cell has been deleted successfully" }); 98 | }); 99 | 100 | const copyPasteCell = asyncHandler(async (req, res) => { 101 | let { cellId } = req.params; 102 | 103 | let columnId = +req.body.columnId; 104 | let rowId = +req.body.rowId; 105 | 106 | let copyCell = await Cell.findById(cellId, { 107 | background: 1, 108 | content: 1, 109 | gridId: 1, 110 | text: 1, 111 | }); 112 | 113 | if (!copyCell) { 114 | throw new CustomError({ message: "Cell not exist", status: 400 }); 115 | } 116 | 117 | let cellData = copyCell.toObject() as any; 118 | delete cellData._id; 119 | 120 | let pasteCell = await Cell.findOne({ 121 | gridId: cellData.gridId, 122 | rowId, 123 | columnId, 124 | }); 125 | 126 | if (pasteCell) { 127 | let cellId = pasteCell._id.toString(); 128 | 129 | await Cell.findByIdAndUpdate(cellId, { $set: cellData }); 130 | 131 | res.status(200).send({ 132 | message: "Success", 133 | data: { 134 | cell: { ...cellData, _id: cellId, rowId, columnId }, 135 | }, 136 | }); 137 | } else { 138 | let cell = await Cell.create({ 139 | ...cellData, 140 | rowId, 141 | columnId, 142 | }); 143 | 144 | res.status(200).send({ message: "Success", data: { cell } }); 145 | } 146 | }); 147 | 148 | const insertColumn = asyncHandler(async (req, res) => { 149 | let { gridId } = req.params; 150 | let { columnId, direction } = req.body; 151 | 152 | columnId = +columnId; 153 | 154 | if (!columnId) { 155 | return res.status(400).send({ message: "ColumnId is required" }); 156 | } 157 | 158 | let grid = await Grid.findById(gridId); 159 | 160 | if (!grid) { 161 | throw new CustomError({ message: "Grid not exist", status: 400 }); 162 | } 163 | 164 | if (direction !== "left" && direction !== "right") 165 | return res.status(400).send({ message: "Invalid direction" }); 166 | 167 | await Cell.updateMany( 168 | { 169 | gridId, 170 | columnId: { [direction === "right" ? "$gt" : "$gte"]: columnId }, 171 | }, 172 | { $inc: { columnId: 1 } } 173 | ); 174 | 175 | res 176 | .status(200) 177 | .send({ message: `Column has been inserted ${direction} successfully` }); 178 | }); 179 | 180 | const insertRow = asyncHandler(async (req, res) => { 181 | let { gridId } = req.params; 182 | let { rowId, direction } = req.body; 183 | 184 | rowId = +rowId; 185 | 186 | if (!rowId) { 187 | return res.status(400).send({ message: "RowId is required" }); 188 | } 189 | 190 | let grid = await Grid.findById(gridId); 191 | 192 | if (!grid) { 193 | throw new CustomError({ message: "Grid not exist", status: 400 }); 194 | } 195 | 196 | if (direction !== "top" && direction !== "bottom") 197 | return res.status(400).send({ message: "Invalid direction" }); 198 | 199 | await Cell.updateMany( 200 | { 201 | gridId, 202 | rowId: { [direction === "bottom" ? "$gt" : "$gte"]: rowId }, 203 | }, 204 | { $inc: { rowId: 1 } } 205 | ); 206 | 207 | res.status(200).send({ message: "Row has been inserted successfully" }); 208 | }); 209 | 210 | const removeRow = asyncHandler(async (req, res) => { 211 | let { gridId } = req.params; 212 | let { rowId } = req.body; 213 | 214 | rowId = +rowId; 215 | 216 | let grid = await Grid.findById(gridId); 217 | 218 | if (!grid) { 219 | throw new CustomError({ message: "Grid not exist", status: 400 }); 220 | } 221 | 222 | await Cell.deleteMany({ gridId, rowId }); 223 | 224 | res.status(200).send({ message: "Row has been deleted successfully" }); 225 | }); 226 | 227 | const removeColumn = asyncHandler(async (req, res) => { 228 | let { gridId } = req.params; 229 | let { columnId } = req.body; 230 | 231 | columnId = +columnId; 232 | 233 | let grid = await Grid.findById(gridId); 234 | 235 | if (!grid) { 236 | throw new CustomError({ message: "Grid not exist", status: 400 }); 237 | } 238 | 239 | await Cell.deleteMany({ gridId, columnId }); 240 | 241 | res.status(200).send({ message: "Column has been deleted successfully" }); 242 | }); 243 | 244 | const CellController = { 245 | createCell, 246 | updateCell, 247 | removeCell, 248 | duplicateCells, 249 | copyPasteCell, 250 | insertColumn, 251 | insertRow, 252 | removeColumn, 253 | removeRow, 254 | }; 255 | 256 | export default CellController; 257 | -------------------------------------------------------------------------------- /client/src/components/Sheet/Grid/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | const colorsData = [ 4 | [ 5 | { 6 | label: "black", 7 | colorCode: "rgb(0, 0, 0)", 8 | }, 9 | { 10 | label: "dark gray 4", 11 | colorCode: "rgb(67, 67, 67)", 12 | }, 13 | { 14 | label: "dark gray 3", 15 | colorCode: "rgb(102, 102, 102)", 16 | }, 17 | { 18 | label: "dark gray 2", 19 | colorCode: "rgb(153, 153, 153)", 20 | }, 21 | { 22 | label: "dark gray 1", 23 | colorCode: "rgb(183, 183, 183)", 24 | }, 25 | { 26 | label: "gray", 27 | colorCode: "rgb(204, 204, 204)", 28 | }, 29 | { 30 | label: "light gray 1", 31 | colorCode: "rgb(217, 217, 217)", 32 | }, 33 | { 34 | label: "light gray 2", 35 | colorCode: "rgb(239, 239, 239)", 36 | }, 37 | { 38 | label: "light gray 3", 39 | colorCode: "rgb(243, 243, 243)", 40 | }, 41 | { 42 | label: "white", 43 | colorCode: "rgb(255, 255, 255)", 44 | }, 45 | ], 46 | [ 47 | { 48 | label: "red berry", 49 | colorCode: "rgb(152, 0, 0)", 50 | }, 51 | { 52 | label: "red", 53 | colorCode: "rgb(255, 0, 0)", 54 | }, 55 | { 56 | label: "orange", 57 | colorCode: "rgb(255, 153, 0)", 58 | }, 59 | { 60 | label: "yellow", 61 | colorCode: "rgb(255, 255, 0)", 62 | }, 63 | { 64 | label: "green", 65 | colorCode: "rgb(0, 255, 0)", 66 | }, 67 | { 68 | label: "cyan", 69 | colorCode: "rgb(0, 255, 255)", 70 | }, 71 | { 72 | label: "cornflower blue", 73 | colorCode: "rgb(74, 134, 232)", 74 | }, 75 | { 76 | label: "blue", 77 | colorCode: "rgb(0, 0, 255)", 78 | }, 79 | { 80 | label: "purple", 81 | colorCode: "rgb(153, 0, 255)", 82 | }, 83 | { 84 | label: "magenta", 85 | colorCode: "rgb(255, 0, 255)", 86 | }, 87 | ], 88 | [ 89 | { 90 | label: "light red berry 3", 91 | colorCode: "rgb(230, 184, 175)", 92 | }, 93 | { 94 | label: "light red 3", 95 | colorCode: "rgb(244, 204, 204)", 96 | }, 97 | { 98 | label: "light orange 3", 99 | colorCode: "rgb(252, 229, 205)", 100 | }, 101 | { 102 | label: "light yellow 3", 103 | colorCode: "rgb(255, 242, 204)", 104 | }, 105 | { 106 | label: "light green 3", 107 | colorCode: "rgb(217, 234, 211)", 108 | }, 109 | { 110 | label: "light cyan 3", 111 | colorCode: "rgb(208, 224, 227)", 112 | }, 113 | { 114 | label: "light cornflower blue 3", 115 | colorCode: "rgb(201, 218, 248)", 116 | }, 117 | { 118 | label: "light blue 3", 119 | colorCode: "rgb(207, 226, 243)", 120 | }, 121 | { 122 | label: "light purple 3", 123 | colorCode: "rgb(217, 210, 233)", 124 | }, 125 | { 126 | label: "light magenta 3", 127 | colorCode: "rgb(234, 209, 220)", 128 | }, 129 | ], 130 | [ 131 | { 132 | label: "light red berry 2", 133 | colorCode: "rgb(221, 126, 107)", 134 | }, 135 | { 136 | label: "light red 2", 137 | colorCode: "rgb(234, 153, 153)", 138 | }, 139 | { 140 | label: "light orange 2", 141 | colorCode: "rgb(249, 203, 156)", 142 | }, 143 | { 144 | label: "light yellow 2", 145 | colorCode: "rgb(255, 229, 153)", 146 | }, 147 | { 148 | label: "light green 2", 149 | colorCode: "rgb(182, 215, 168)", 150 | }, 151 | { 152 | label: "light cyan 2", 153 | colorCode: "rgb(162, 196, 201)", 154 | }, 155 | { 156 | label: "light cornflower blue 2", 157 | colorCode: "rgb(164, 194, 244)", 158 | }, 159 | { 160 | label: "light blue 2", 161 | colorCode: "rgb(159, 197, 232)", 162 | }, 163 | { 164 | label: "light purple 2", 165 | colorCode: "rgb(180, 167, 214)", 166 | }, 167 | { 168 | label: "light magenta 2", 169 | colorCode: "rgb(213, 166, 189)", 170 | }, 171 | ], 172 | [ 173 | { 174 | label: "light red berry 1", 175 | colorCode: "rgb(204, 65, 37)", 176 | }, 177 | { 178 | label: "light red 1", 179 | colorCode: "rgb(224, 102, 102)", 180 | }, 181 | { 182 | label: "light orange 1", 183 | colorCode: "rgb(246, 178, 107)", 184 | }, 185 | { 186 | label: "light yellow 1", 187 | colorCode: "rgb(255, 217, 102)", 188 | }, 189 | { 190 | label: "light green 1", 191 | colorCode: "rgb(147, 196, 125)", 192 | }, 193 | { 194 | label: "light cyan 1", 195 | colorCode: "rgb(118, 165, 175)", 196 | }, 197 | { 198 | label: "light cornflower blue 1", 199 | colorCode: "rgb(109, 158, 235)", 200 | }, 201 | { 202 | label: "light blue 1", 203 | colorCode: "rgb(111, 168, 220)", 204 | }, 205 | { 206 | label: "light purple 1", 207 | colorCode: "rgb(142, 124, 195)", 208 | }, 209 | { 210 | label: "light magenta 1", 211 | colorCode: "rgb(194, 123, 160)", 212 | }, 213 | ], 214 | [ 215 | { 216 | label: "dark red berry 1", 217 | colorCode: "rgb(166, 28, 0)", 218 | }, 219 | { 220 | label: "dark red 1", 221 | colorCode: "rgb(204, 0, 0)", 222 | }, 223 | { 224 | label: "dark orange 1", 225 | colorCode: "rgb(230, 145, 56)", 226 | }, 227 | { 228 | label: "dark yellow 1", 229 | colorCode: "rgb(241, 194, 50)", 230 | }, 231 | { 232 | label: "dark green 1", 233 | colorCode: "rgb(106, 168, 79)", 234 | }, 235 | { 236 | label: "dark cyan 1", 237 | colorCode: "rgb(69, 129, 142)", 238 | }, 239 | { 240 | label: "dark cornflower blue 1", 241 | colorCode: "rgb(60, 120, 216)", 242 | }, 243 | { 244 | label: "dark blue 1", 245 | colorCode: "rgb(61, 133, 198)", 246 | }, 247 | { 248 | label: "dark purple 1", 249 | colorCode: "rgb(103, 78, 167)", 250 | }, 251 | { 252 | label: "dark magenta 1", 253 | colorCode: "rgb(166, 77, 121)", 254 | }, 255 | ], 256 | [ 257 | { 258 | label: "dark red berry 2", 259 | colorCode: "rgb(133, 32, 12)", 260 | }, 261 | { 262 | label: "dark red 2", 263 | colorCode: "rgb(153, 0, 0)", 264 | }, 265 | { 266 | label: "dark orange 2", 267 | colorCode: "rgb(180, 95, 6)", 268 | }, 269 | { 270 | label: "dark yellow 2", 271 | colorCode: "rgb(191, 144, 0)", 272 | }, 273 | { 274 | label: "dark green 2", 275 | colorCode: "rgb(56, 118, 29)", 276 | }, 277 | { 278 | label: "dark cyan 2", 279 | colorCode: "rgb(19, 79, 92)", 280 | }, 281 | { 282 | label: "dark cornflower blue 2", 283 | colorCode: "rgb(17, 85, 204)", 284 | }, 285 | { 286 | label: "dark blue 2", 287 | colorCode: "rgb(11, 83, 148)", 288 | }, 289 | { 290 | label: "dark purple 2", 291 | colorCode: "rgb(53, 28, 117)", 292 | }, 293 | { 294 | label: "dark magenta 2", 295 | colorCode: "rgb(116, 27, 71)", 296 | }, 297 | ], 298 | [ 299 | { 300 | label: "dark red berry 3", 301 | colorCode: "rgb(91, 15, 0)", 302 | }, 303 | { 304 | label: "dark red 3", 305 | colorCode: "rgb(102, 0, 0)", 306 | }, 307 | { 308 | label: "dark orange 3", 309 | colorCode: "rgb(120, 63, 4)", 310 | }, 311 | { 312 | label: "dark yellow 3", 313 | colorCode: "rgb(127, 96, 0)", 314 | }, 315 | { 316 | label: "dark green 3", 317 | colorCode: "rgb(39, 78, 19)", 318 | }, 319 | { 320 | label: "dark cyan 3", 321 | colorCode: "rgb(12, 52, 61)", 322 | }, 323 | { 324 | label: "dark cornflower blue 3", 325 | colorCode: "rgb(28, 69, 135)", 326 | }, 327 | { 328 | label: "dark blue 3", 329 | colorCode: "rgb(7, 55, 99)", 330 | }, 331 | { 332 | label: "dark purple 3", 333 | colorCode: "rgb(32, 18, 77)", 334 | }, 335 | { 336 | label: "dark magenta 3", 337 | colorCode: "rgb(76, 17, 48)", 338 | }, 339 | ], 340 | ] as const; 341 | 342 | const lightColors = new Set([ 343 | "light gray 2", 344 | "light gray 1", 345 | "light gray 3", 346 | "white", 347 | ]); 348 | 349 | type IColorPickerProps = { 350 | onClick: (colorCode: string) => void; 351 | }; 352 | 353 | const ColorPicker = ({ onClick }: IColorPickerProps) => { 354 | return ( 355 |
356 | {colorsData.map((colors, index) => { 357 | return ( 358 |
359 | {colors.map(({ colorCode, label }) => { 360 | return ( 361 | 370 | ); 371 | })} 372 |
373 | ); 374 | })} 375 |
376 | ); 377 | }; 378 | 379 | export default ColorPicker; 380 | -------------------------------------------------------------------------------- /client/src/pages/SheetList/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useRef, useState, ChangeEvent } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | import { Menu, MenuList, MenuButton, Portal, MenuItem } from "@chakra-ui/react"; 4 | import dayjs from "dayjs"; 5 | import utc from "dayjs/plugin/utc"; 6 | import timezone from "dayjs/plugin/timezone"; 7 | import relativeTime from "dayjs/plugin/relativeTime"; 8 | import { toast } from "react-toastify"; 9 | import { useAuth } from "@/hooks/useAuth"; 10 | import Pagination from "@/components/Pagination"; 11 | import Avatar from "@/components/Avatar"; 12 | import { createSheet, getSheetList, removeSheetById } from "@/services/Sheet"; 13 | import { getStaticUrl, debounce } from "@/utils"; 14 | 15 | dayjs.extend(utc); 16 | dayjs.extend(timezone); 17 | dayjs.extend(relativeTime); 18 | 19 | const SheetList = () => { 20 | const [sheets, setSheets] = useState([]); 21 | 22 | const [pageMeta, setPageMeta] = useState({} as any); 23 | 24 | const [isLoading, setIsLoading] = useState(true); 25 | 26 | const location = useLocation(); 27 | 28 | const navigate = useNavigate(); 29 | 30 | const { user, logout } = useAuth(); 31 | 32 | let containerRef = useRef(null); 33 | 34 | const searchParams = new URLSearchParams(location.search); 35 | 36 | let search = searchParams.get("search") || ""; 37 | let page = searchParams.get("page") || 1; 38 | 39 | useEffect(() => { 40 | getSheetDetails(); 41 | }, [search, page]); 42 | 43 | const getSheetDetails = async () => { 44 | try { 45 | let { 46 | data: { 47 | data: { sheets, pageMeta }, 48 | }, 49 | } = await getSheetList({ limit: 15, search, page: +page }); 50 | setSheets(sheets); 51 | setPageMeta(pageMeta); 52 | } catch (err: any) { 53 | toast.error(err?.message); 54 | } finally { 55 | if (isLoading) setIsLoading(false); 56 | } 57 | }; 58 | 59 | const handleCreateDocument = async () => { 60 | try { 61 | let { 62 | data: { 63 | data: { sheetId }, 64 | }, 65 | } = await createSheet(); 66 | navigate(`/sheet/${sheetId}`); 67 | } catch (error: any) { 68 | toast.error(error?.message); 69 | } 70 | }; 71 | 72 | const handleDeleteDocument = async (sheetId: string) => { 73 | if (!window.confirm("Are you sure to delete this form?")) return; 74 | 75 | try { 76 | await removeSheetById(sheetId); 77 | getSheetDetails(); 78 | } catch (error: any) { 79 | toast.error(error?.message); 80 | } 81 | }; 82 | 83 | const handlePageChange = (page: number) => { 84 | if (!containerRef.current) return; 85 | 86 | navigate({ 87 | search: 88 | page !== 0 89 | ? `?page=${page + 1}${search ? `&search=${search}` : ""}` 90 | : "", 91 | }); 92 | 93 | containerRef.current.scrollIntoView({ behavior: "smooth" }); 94 | }; 95 | 96 | const navigateToSheet = (sheetId: string, newTab: boolean = false) => { 97 | let path = `/sheet/${sheetId}`; 98 | newTab ? window.open(`#${path}`) : navigate(path); 99 | }; 100 | 101 | const handleChange = debounce>( 102 | ({ target: { value } }) => { 103 | navigate({ search: value ? `?search=${value}` : "" }); 104 | }, 105 | 500 106 | ); 107 | 108 | return ( 109 | 110 |
111 |
112 | 113 | 114 | Google Sheets 115 | 116 |
117 |
118 | 124 | 125 |
126 | {user && } 127 |
128 | {isLoading ? ( 129 |
130 | Loading... 131 |
132 | ) : ( 133 | 134 | 138 | 139 | 140 | 143 | 146 | 149 | 152 | 153 | 154 | 155 | {sheets.length === 0 ? ( 156 | 157 | 160 | 161 | ) : ( 162 | sheets.map(({ title, _id, createdAt, lastOpenedAt }) => { 163 | return ( 164 | 165 | navigateToSheet(_id)} 168 | > 169 | 178 | 185 | 192 | 228 | 229 | 230 | ); 231 | }) 232 | )} 233 | 234 |
141 | Title 142 | 144 | Created at 145 | 147 | Last opened by me 148 | 150 | Action 151 |
158 | No Records Found 159 |
170 |
171 | 175 | {title} 176 |
177 |
179 | 180 | {dayjs 181 | .tz(new Date(createdAt), "Asia/Kolkata") 182 | .format("MMM D, YYYY")} 183 | 184 | 186 | 187 | {dayjs 188 | .tz(new Date(lastOpenedAt), "Asia/Kolkata") 189 | .fromNow()} 190 | 191 | 193 |
194 | 195 | e.stopPropagation()} 198 | > 199 | 200 | 201 | 202 | 203 | { 206 | event.stopPropagation(); 207 | handleDeleteDocument(_id); 208 | }} 209 | > 210 | 211 | Remove 212 | 213 | { 216 | event.stopPropagation(); 217 | navigateToSheet(_id, true); 218 | }} 219 | > 220 | 221 | Open in new tab 222 | 223 | 224 | 225 | 226 |
227 |
235 | {pageMeta.totalPages > 1 && ( 236 | 237 | )} 238 |
239 | )} 240 | 246 |
247 | ); 248 | }; 249 | 250 | export default SheetList; 251 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": { 33 | // "@/*": [ 34 | // "src/*" 35 | // ] 36 | // }, /* Specify a set of entries that re-map imports to additional lookup locations. */ 37 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 38 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 39 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 40 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 41 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 44 | 45 | /* JavaScript Support */ 46 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 47 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 48 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 49 | 50 | /* Emit */ 51 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 52 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 53 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 54 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 55 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 56 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 57 | // "removeComments": true, /* Disable emitting comments. */ 58 | // "noEmit": true, /* Disable emitting files from a compilation. */ 59 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 60 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 61 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 62 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 65 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 66 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 67 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 68 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 69 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 70 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 71 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 72 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 73 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 74 | 75 | /* Interop Constraints */ 76 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 77 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 78 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 79 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 80 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 81 | 82 | /* Type Checking */ 83 | "strict": true, /* Enable all strict type-checking options. */ 84 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 85 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 86 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 87 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 88 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 89 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 90 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 91 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 92 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 93 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 94 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 95 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 96 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 97 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 98 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 99 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 100 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 101 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 102 | 103 | /* Completeness */ 104 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 105 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /client/src/components/Sheet/ToolBar.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, Fragment, useEffect, useState } from "react"; 2 | import classNames from "classnames"; 3 | import { 4 | Box, 5 | Menu, 6 | MenuButton, 7 | MenuList, 8 | MenuItem, 9 | Portal, 10 | Tooltip, 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | } from "@chakra-ui/react"; 15 | import { useSheet } from "@/hooks/useSheet"; 16 | import ColorPicker from "./Grid/ColorPicker"; 17 | import { debounce } from "@/utils"; 18 | import { config } from "@/constants"; 19 | 20 | const activeClassName = "bg-light-blue rounded"; 21 | const btnClassName = "flex justify-center items-center w-[24px] h-[24px]"; 22 | const hoverClassName = "hover:bg-[#DFE4EB] p-1 rounded transition-colors"; 23 | 24 | const DEFAULT_ACTIVE_STYLE: IActiveStyle = { 25 | bold: false, 26 | strike: false, 27 | italic: false, 28 | font: config.defaultFont, 29 | underline: false, 30 | background: "#ffffff", 31 | color: "#000000", 32 | size: config.defaultFontSize, 33 | }; 34 | 35 | const ToolBar = () => { 36 | const [activeStyle, setActiveStyle] = 37 | useState>(DEFAULT_ACTIVE_STYLE); 38 | 39 | const { 40 | quill, 41 | scale, 42 | editCell, 43 | selectedCell, 44 | activeHighLightIndex, 45 | highLightCells, 46 | getCellById, 47 | handleSearchSheet, 48 | handleFormatCell, 49 | handleSearchNext, 50 | handleSearchPrevious, 51 | handleScaleChange, 52 | } = useSheet(); 53 | 54 | let { background } = getCellById(selectedCell?.cellId) || {}; 55 | 56 | useEffect(() => { 57 | if (!quill) return; 58 | 59 | quill.on("selection-change", handleSelectionChange); 60 | return () => { 61 | quill.off("selection-change", handleSelectionChange); 62 | }; 63 | }, [quill]); 64 | 65 | useEffect(() => { 66 | if (!selectedCell) return; 67 | setActiveStyle({ 68 | ...DEFAULT_ACTIVE_STYLE, 69 | font: config.defaultFont, 70 | background: background || DEFAULT_ACTIVE_STYLE.background, 71 | }); 72 | }, [selectedCell]); 73 | 74 | const handleSelectionChange = () => { 75 | if (!quill) return; 76 | 77 | let { 78 | bold, 79 | strike, 80 | font, 81 | underline, 82 | color, 83 | italic, 84 | size = config.defaultFontSize, 85 | } = quill.getFormat(); 86 | 87 | setActiveStyle({ 88 | bold: !!bold, 89 | strike: !!strike, 90 | underline: !!underline, 91 | italic: !!italic, 92 | font: font || DEFAULT_ACTIVE_STYLE.font, 93 | color: color || DEFAULT_ACTIVE_STYLE.color, 94 | size: Array.isArray(size) && size.length ? size[size.length - 1] : size, 95 | }); 96 | }; 97 | 98 | const formatText: IFormatText = (type, value) => { 99 | if (type === "background" || type === "align") { 100 | handleFormatCell(type, value as string); 101 | setActiveStyle({ ...activeStyle, [type]: value }); 102 | } else { 103 | if (!quill) return; 104 | quill.format(type, value); 105 | setActiveStyle({ ...activeStyle, [type]: value }); 106 | } 107 | }; 108 | 109 | const handleRemoveFormat = () => { 110 | if (!quill) return; 111 | let selection = quill.getSelection(); 112 | if (!selection) return; 113 | quill.removeFormat(selection.index, selection.length); 114 | handleSelectionChange(); 115 | }; 116 | 117 | const handleSearch = debounce>( 118 | (e) => handleSearchSheet(e.target.value), 119 | 500 120 | ); 121 | 122 | const Divider = () =>
; 123 | 124 | return ( 125 | 126 |
127 | {/*
128 | 131 | 134 |
135 | */} 136 |
137 | 138 | {({ isOpen }) => ( 139 | 140 | 141 | 142 |
143 | 144 | {scale * 100}% 145 | 146 | 152 |
153 |
154 |
155 | 156 | 161 | {config.scale.map((value, index) => { 162 | return ( 163 | handleScaleChange(value)} 167 | > 168 | {value * 100}% 169 | 170 | ); 171 | })} 172 | 173 | 174 |
175 | )} 176 |
177 |
178 | 179 |
180 | 181 | {({ isOpen }) => ( 182 | 183 | 184 | 188 |
189 | 190 | {config.fonts[activeStyle.font as string]} 191 | 192 | 198 |
199 |
200 |
201 | 202 | 206 | {config.customFonts.map((value, index) => { 207 | return ( 208 | formatText("font", value)} 212 | > 213 | {config.fonts[value]} 214 | 215 | ); 216 | })} 217 | 218 | 219 |
220 | )} 221 |
222 |
223 | 224 | {({ isOpen }) => ( 225 | 226 | 231 | 235 |
236 | 237 | {activeStyle.size} 238 | 239 | 245 |
246 |
247 |
248 | 249 | 254 | {config.fontSizes.map((value, index) => { 255 | return ( 256 | formatText("size", value)} 260 | > 261 | {value} 262 | 263 | ); 264 | })} 265 | 266 | 267 |
268 | )} 269 |
270 |
271 |
272 | 273 |
274 | 275 | 284 | 285 | 286 | 295 | 296 | 297 | 306 | 307 | 308 | 317 | 318 |
319 | 320 |
321 | 322 | {({ onClose }) => { 323 | return ( 324 | 325 | 326 | 327 | 328 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | { 350 | formatText("color", color); 351 | onClose(); 352 | }} 353 | /> 354 | 355 | 356 | 357 | 358 | ); 359 | }} 360 | 361 | 362 | {({ onClose }) => { 363 | return ( 364 | 365 | 366 | 367 | 368 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | { 390 | formatText("background", color); 391 | onClose(); 392 | }} 393 | /> 394 | 395 | 396 | 397 | 398 | ); 399 | }} 400 | 401 |
402 | 403 |
404 | 405 | 412 | 413 |
414 | 415 | {/*
416 | 417 | 420 | 421 | 422 | 425 | 426 | 427 | 430 | 431 |
432 | */} 433 | {/*
434 | 435 | 438 | 439 | 440 | 443 | 444 |
445 | */} 446 |
447 |
448 | 453 | 454 |
455 | {!!highLightCells.length && activeHighLightIndex !== null && ( 456 | 457 | 463 | 469 | 470 | 471 | {activeHighLightIndex + 1} of {highLightCells.length} 472 | 473 | 474 | )} 475 |
476 |
477 |
478 | ); 479 | }; 480 | 481 | export default ToolBar; 482 | --------------------------------------------------------------------------------