├── .babelrc ├── .eslintrc.js ├── .github └── pull_request_template.md ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── backend └── src │ ├── app.ts │ ├── index.ts │ ├── middlewares │ └── useHttps.ts │ ├── routes │ ├── pingback.ts │ └── upload.ts │ ├── server.ts │ ├── sockets │ ├── cache.ts │ ├── index.ts │ ├── middlewares │ │ └── checkRoom.ts │ ├── onChangePage.ts │ ├── onDisconnect.ts │ ├── onJoinRoom.ts │ ├── onMouseMove.ts │ ├── onToolColorChange.ts │ ├── onUpdatePresenter.ts │ ├── onUpdateScroll.ts │ ├── onUpdateZoom.ts │ └── types.ts │ └── utils │ ├── createRoom.ts │ └── s3.ts ├── frontend ├── index.html ├── src │ ├── components │ │ ├── App.tsx │ │ ├── BeamingModal │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── DocumentView │ │ │ ├── hooks │ │ │ │ └── usePanhandler.ts │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Home │ │ │ ├── Prompt │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── SelectPDF │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── UploadPDF │ │ │ │ ├── index.tsx │ │ │ │ ├── keyframes.ts │ │ │ │ └── styles.ts │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── LinkModal │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Loading │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── NavBar │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Pointer │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Room │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ ├── usePointer.ts │ │ │ │ └── useSocket.ts │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── TestRoutes.tsx │ │ ├── ToolBar │ │ │ ├── Palette │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── ZoomBar │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ └── globalStyles.ts │ ├── index.tsx │ ├── socket.ts │ ├── store │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── reducers.ts │ │ └── types.ts │ └── utils │ │ ├── apis.ts │ │ ├── paddingUtils.ts │ │ ├── roundTo.ts │ │ └── tools.ts └── static │ ├── icons │ ├── close.svg │ ├── closeLight.svg │ ├── comment.svg │ ├── commentLight.svg │ ├── copyPasteboard.svg │ ├── copyPasteboardLight.svg │ ├── draw.svg │ ├── drawLight.svg │ ├── erase.svg │ ├── eraseLight.svg │ ├── firstLastPage.svg │ ├── firstLastPageLight.svg │ ├── folder.svg │ ├── folderDark.svg │ ├── pointer.svg │ ├── pointerLight.svg │ ├── presenter.svg │ ├── presenterLight.svg │ ├── prevNextPage.svg │ ├── prevNextPageLight.svg │ ├── zoomIn.svg │ ├── zoomInLight.svg │ ├── zoomOut.svg │ ├── zoomOutLight.svg │ ├── zoomReset.svg │ └── zoomResetLight.svg │ ├── normalize.css │ ├── spinner.svg │ ├── spinnerLight.svg │ └── style.css ├── nodemon.json ├── package-lock.json ├── package.json ├── readme.md ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["react-hot-loader/babel"], 3 | "presets": ["@babel/react"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:prettier/recommended", 12 | "prettier/@typescript-eslint", 13 | ], 14 | globals: { 15 | Atomics: "readonly", 16 | SharedArrayBuffer: "readonly", 17 | }, 18 | parser: "@typescript-eslint/parser", 19 | parserOptions: { 20 | ecmaFeatures: { 21 | jsx: true, 22 | }, 23 | ecmaVersion: 2018, 24 | sourceType: "module", 25 | }, 26 | plugins: ["react", "@typescript-eslint", "prettier"], 27 | rules: { 28 | "react/prop-types": 0, 29 | "no-unused-vars": "off", 30 | "@typescript-eslint/no-unused-vars": "error", 31 | "@typescript-eslint/explicit-function-return-type": 0, 32 | }, 33 | overrides: [ 34 | { 35 | files: ["*.ts", "*.tsx"], 36 | rules: { 37 | "@typescript-eslint/explicit-function-return-type": ["error"], 38 | }, 39 | }, 40 | ], 41 | } 42 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What does this PR fix? 2 | - Fixes # 3 | - 4 | 5 | ## How? 6 | - 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | sample-pdfs/ 3 | frontend/dist/ 4 | backend/dist/ 5 | *.swp 6 | .DS_Store 7 | .env 8 | .eslintcache 9 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | semi: false 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 JT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Application, Request, Response } from "express" 2 | import bodyParser from "body-parser" 3 | import path from "path" 4 | import compression from "compression" 5 | 6 | // Middlewares 7 | import useHttps from "./middlewares/useHttps" 8 | 9 | // Routes 10 | import uploadRouter from "./routes/upload" 11 | import pingbackRouter from "./routes/pingback" 12 | 13 | const app: Application = express() 14 | 15 | app.use(useHttps) 16 | app.use(bodyParser.json()) 17 | app.use(compression()) 18 | 19 | app.use("/", express.static(path.join(__dirname, "../../frontend/dist"))) 20 | app.use( 21 | "/static", 22 | express.static(path.join(__dirname, "../../frontend/static")) 23 | ) 24 | 25 | app.get("/", (req: Request, res: Response): void => { 26 | res.sendFile(path.join(__dirname, "../../frontend/index.html")) 27 | }) 28 | 29 | app.use("/api/upload", uploadRouter) 30 | app.use("/api/pingback", pingbackRouter) 31 | 32 | export default app 33 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | require("dotenv").config() 2 | 3 | import server from "./server" 4 | import sockets from "./sockets" 5 | 6 | sockets(server) 7 | -------------------------------------------------------------------------------- /backend/src/middlewares/useHttps.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express" 2 | 3 | const useHttps = (req: Request, res: Response, next: NextFunction): void => { 4 | const forwardedProtocol = req.headers["x-forwarded-proto"] || "" 5 | const isSecure = req.secure || forwardedProtocol === "https" 6 | 7 | if (req.hostname === "localhost" || isSecure) { 8 | next() 9 | } else { 10 | const httpsUrl = `https://${req.get("host")}${req.originalUrl}` 11 | res.redirect(301, httpsUrl) 12 | } 13 | } 14 | 15 | export default useHttps 16 | -------------------------------------------------------------------------------- /backend/src/routes/pingback.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express" 2 | import { rooms } from "../sockets/cache" 3 | import { io } from "../sockets" 4 | 5 | import { RoomData } from "../sockets/types" 6 | import createRoom from "../utils/createRoom" 7 | 8 | const router = express.Router() 9 | const s3Url = "https://beam-me-up-scotty.s3.amazonaws.com" 10 | 11 | // Send response back and create room 12 | router.post("/", (req: Request, res: Response) => { 13 | res.sendStatus(204) 14 | 15 | const { status, message, forwardData } = req.body 16 | const { hostID, roomID, filename } = JSON.parse(forwardData) 17 | 18 | console.log(message) 19 | 20 | if (status === "error") { 21 | io.to(hostID).emit("conveyor error", message) 22 | } else if (status === "processing") { 23 | io.to(hostID).emit("conveyor update", message) 24 | } else if (status === "end") { 25 | const { s3Dir, files } = message 26 | 27 | const newRoom = createRoom(filename, s3Dir, `${s3Url}/${s3Dir}`, files) 28 | 29 | rooms[roomID] = newRoom 30 | 31 | // Send private message back to room creator with roomID 32 | const roomCreatedData: RoomData = { roomID } 33 | io.to(hostID).emit("room created", roomCreatedData) 34 | } 35 | }) 36 | 37 | export default router 38 | -------------------------------------------------------------------------------- /backend/src/routes/upload.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express" 2 | import { v4 } from "uuid" 3 | 4 | import s3 from "../utils/s3" 5 | 6 | const router = express.Router() 7 | 8 | // get Key and URL from S3 9 | router.get("/", (req: Request, res: Response): void => { 10 | const Key = `${v4()}.pdf` 11 | 12 | s3.getSignedUrl( 13 | "putObject", 14 | { 15 | Bucket: process.env.S3_BUCKET, 16 | ContentType: "application/pdf", 17 | Key, 18 | }, 19 | (err: Error, url: string): void => { 20 | if (err) throw err 21 | res.send({ Key, url }) 22 | } 23 | ) 24 | }) 25 | 26 | export default router 27 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import { Server as HTTPserver } from "http" 2 | import https, { Server as HTTPSserver } from "https" 3 | 4 | import app from "./app" 5 | 6 | const PORT = process.env.PORT || 3030 7 | 8 | let server: HTTPserver | HTTPSserver 9 | if (process.env.NOD_ENV === "production") { 10 | server = https.createServer(app).listen(PORT) 11 | } else { 12 | server = app.listen(PORT, () => console.log(`Listening to port ${PORT}`)) 13 | } 14 | export default server 15 | -------------------------------------------------------------------------------- /backend/src/sockets/cache.ts: -------------------------------------------------------------------------------- 1 | import { RoomsMap, UsersMap } from "./types" 2 | 3 | export const rooms: RoomsMap = {} 4 | export const usersMap: UsersMap = {} 5 | -------------------------------------------------------------------------------- /backend/src/sockets/index.ts: -------------------------------------------------------------------------------- 1 | import { Server as HTTPserver } from "http" 2 | import { Server as HTTPSserver } from "https" 3 | 4 | import socket, { Server as SocketServer } from "socket.io" 5 | 6 | import checkRoom from "./middlewares/checkRoom" 7 | import { Connection } from "./types" 8 | 9 | import onChangePage from "./onChangePage" 10 | import onDisconnect from "./onDisconnect" 11 | import onJoinRoom from "./onJoinRoom" 12 | import onMouseMove from "./onMouseMove" 13 | import onToolColorChange from "./onToolColorChange" 14 | import onUpdatePresenter from "./onUpdatePresenter" 15 | import onUpdateScroll from "./onUpdateScroll" 16 | import onUpdateZoom from "./onUpdateZoom" 17 | 18 | export let io: SocketServer 19 | 20 | export default (server: HTTPserver | HTTPSserver): void => { 21 | io = socket(server, { cookie: false }) 22 | 23 | io.on("connection", socket => { 24 | const connection: Connection = { 25 | io, 26 | socket, 27 | } 28 | 29 | socket.use((packet, next) => checkRoom(connection, packet, next)) 30 | 31 | socket.on("join room", data => { 32 | onJoinRoom(connection, data) 33 | }) 34 | 35 | socket.on("client change page", data => { 36 | onChangePage(connection, data) 37 | }) 38 | 39 | socket.on("leave room", () => { 40 | onDisconnect(connection) 41 | }) 42 | 43 | socket.on("disconnect", () => { 44 | onDisconnect(connection) 45 | }) 46 | 47 | socket.on("mousemove", data => { 48 | onMouseMove(connection, data) 49 | }) 50 | 51 | socket.on("change tool color", data => { 52 | onToolColorChange(connection, data) 53 | }) 54 | 55 | socket.on("client update presenter", data => { 56 | onUpdatePresenter(connection, data) 57 | }) 58 | 59 | socket.on("client update scroll", data => { 60 | onUpdateScroll(connection, data) 61 | }) 62 | 63 | socket.on("client update zoom", data => { 64 | onUpdateZoom(connection, data) 65 | }) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /backend/src/sockets/middlewares/checkRoom.ts: -------------------------------------------------------------------------------- 1 | import { Packet } from "socket.io" 2 | 3 | import { rooms } from "../cache" 4 | import { Connection, RoomData } from "../types" 5 | 6 | export default ( 7 | connection: Connection, 8 | packet: Packet, 9 | next: () => void 10 | ): void => { 11 | const { io, socket } = connection 12 | const data: RoomData = packet[1] 13 | 14 | const { roomID } = data 15 | 16 | const room = rooms[roomID] 17 | if (!room) { 18 | io.to(socket.id).emit("error", { message: `Room ${roomID} doesn't exist` }) 19 | return 20 | } 21 | 22 | next() 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/sockets/onChangePage.ts: -------------------------------------------------------------------------------- 1 | import { rooms } from "./cache" 2 | import { Connection, ChangePageData, SyncPageData } from "./types" 3 | 4 | export default (connection: Connection, data: ChangePageData): void => { 5 | const { socket } = connection 6 | const { roomID, pageNum } = data 7 | 8 | const room = rooms[roomID] 9 | room.pageNum = pageNum 10 | 11 | const syncPageData: SyncPageData = { pageNum: room.pageNum } 12 | socket.broadcast.to(roomID).emit("sync page", syncPageData) 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/sockets/onDisconnect.ts: -------------------------------------------------------------------------------- 1 | import s3 from "../utils/s3" 2 | import { rooms, usersMap } from "./cache" 3 | 4 | import { Connection, User, UsersData } from "./types" 5 | 6 | export default (connection: Connection): void => { 7 | const { io, socket } = connection 8 | 9 | const roomID = usersMap[socket.id] 10 | if (!roomID) return 11 | 12 | const room = rooms[roomID] 13 | if (!room) return 14 | 15 | room.users = room.users.filter((user: User) => user.id !== socket.id) 16 | 17 | // When room is empty, delete object from S3 bucket 18 | // and clear the associated value in our cache 19 | if (!room.users.length) { 20 | const { s3Dir, pages } = rooms[roomID] 21 | 22 | const params = { 23 | Bucket: process.env.S3_BUCKET, 24 | Delete: { 25 | Objects: pages.map(page => ({ Key: `${s3Dir}/${page}` })), 26 | }, 27 | } 28 | 29 | s3.deleteObjects(params) 30 | .promise() 31 | .then(data => console.log("DATA", data)) 32 | .catch(err => console.error(err, err.stack)) 33 | 34 | rooms[roomID] = null 35 | } 36 | 37 | usersMap[socket.id] = null 38 | 39 | const usersData: UsersData = { users: room.users } 40 | 41 | // If presenter leaves the room remove presenterID 42 | if (socket.id === room.presenterID) { 43 | room.presenterID = "" 44 | usersData.presenterID = room.presenterID 45 | } 46 | 47 | io.to(roomID).emit("update users", usersData) 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/sockets/onJoinRoom.ts: -------------------------------------------------------------------------------- 1 | import { usersMap, rooms } from "./cache" 2 | import { 3 | Connection, 4 | JoinRoomData, 5 | SyncDocData, 6 | SyncPageData, 7 | User, 8 | UsersData, 9 | } from "./types" 10 | 11 | const createUser = (id: string, toolColor: string): User => { 12 | return { 13 | id, 14 | mouseX: 0, 15 | mouseY: 0, 16 | toolColor, 17 | } 18 | } 19 | 20 | export default (connection: Connection, data: JoinRoomData): void => { 21 | const { io, socket } = connection 22 | const { roomID, toolColor } = data 23 | const room = rooms[roomID] 24 | 25 | room.users.push(createUser(socket.id, toolColor)) 26 | usersMap[socket.id] = roomID 27 | 28 | socket.join(roomID) 29 | 30 | // Make sure we're looking at the same doc & page when joining 31 | // and send userID to client 32 | const pdfUrl = room.pdfUrl || "" 33 | const { pages, filename, presenterID, zoom, scrollLeft, scrollTop } = room 34 | 35 | const syncDocData: SyncDocData = { 36 | pdfUrl, 37 | userID: socket.id, 38 | pages, 39 | filename, 40 | presenterID, 41 | zoom, 42 | scrollLeft, 43 | scrollTop, 44 | } 45 | io.to(socket.id).emit("sync document", syncDocData) 46 | 47 | const pageNum = room.pageNum || 1 48 | const syncPageData: SyncPageData = { pageNum } 49 | io.to(socket.id).emit("sync page", syncPageData) 50 | 51 | const usersData: UsersData = { users: room.users } 52 | io.to(roomID).emit("update users", usersData) 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/sockets/onMouseMove.ts: -------------------------------------------------------------------------------- 1 | import { rooms } from "./cache" 2 | import { Connection, MouseMoveData, UsersData } from "./types" 3 | 4 | export default (connection: Connection, data: MouseMoveData): void => { 5 | const { socket } = connection 6 | const { roomID, mouseX, mouseY } = data 7 | const room = rooms[roomID] 8 | 9 | room.users = room.users.map(user => { 10 | if (user.id === socket.id) { 11 | user.mouseX = mouseX 12 | user.mouseY = mouseY 13 | } 14 | return user 15 | }) 16 | 17 | const usersData: UsersData = { users: room.users } 18 | socket.broadcast.to(roomID).emit("update users", usersData) 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/sockets/onToolColorChange.ts: -------------------------------------------------------------------------------- 1 | import { rooms } from "./cache" 2 | import { Connection, ToolColorChangeData } from "./types" 3 | 4 | export default (connection: Connection, data: ToolColorChangeData): void => { 5 | const { socket } = connection 6 | const { roomID, toolColor } = data 7 | const room = rooms[roomID] 8 | 9 | // Update only. No need to broadcast/emit 10 | // because onMouseMove does that 11 | room.users = room.users.map(user => { 12 | if (user.id === socket.id) { 13 | user.toolColor = toolColor 14 | } 15 | return user 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/sockets/onUpdatePresenter.ts: -------------------------------------------------------------------------------- 1 | import { rooms } from "./cache" 2 | import { Connection, RoomData, PresenterData } from "./types" 3 | 4 | export default (connection: Connection, data: RoomData): void => { 5 | const { socket, io } = connection 6 | const { roomID } = data 7 | 8 | const room = rooms[roomID] 9 | 10 | // If the client sending message is the same as current presenter, 11 | // toggle presenter off; otherwise set that client as presenter 12 | let presenterData: PresenterData 13 | if (room.presenterID && room.presenterID === socket.id) { 14 | room.presenterID = "" 15 | presenterData = { presenterID: "" } 16 | } else { 17 | room.presenterID = socket.id 18 | presenterData = { presenterID: socket.id } 19 | } 20 | 21 | io.to(roomID).emit("update presenter", presenterData) 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/sockets/onUpdateScroll.ts: -------------------------------------------------------------------------------- 1 | import { Connection, ChangeScrollData, SyncScrollData } from "./types" 2 | import { rooms } from "./cache" 3 | 4 | export default (connection: Connection, data: ChangeScrollData): void => { 5 | const { socket } = connection 6 | const { roomID, scrollLeft, scrollTop } = data 7 | 8 | const room = rooms[roomID] 9 | room.scrollLeft = scrollLeft 10 | room.scrollTop = scrollTop 11 | 12 | const scrollData: SyncScrollData = { scrollLeft, scrollTop } 13 | socket.broadcast.to(roomID).emit("update scroll", scrollData) 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/sockets/onUpdateZoom.ts: -------------------------------------------------------------------------------- 1 | import { Connection, ChangeZoomData, SyncZoomData } from "./types" 2 | import { rooms } from "./cache" 3 | 4 | export default (connection: Connection, data: ChangeZoomData): void => { 5 | const { socket } = connection 6 | const { roomID, zoom } = data 7 | 8 | const room = rooms[roomID] 9 | room.zoom = zoom 10 | 11 | const zoomData: SyncZoomData = { zoom } 12 | socket.broadcast.to(roomID).emit("update zoom", zoomData) 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/sockets/types.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io" 2 | 3 | export type Connection = { 4 | io: Server 5 | socket: Socket 6 | } 7 | 8 | export type User = { 9 | id: string 10 | mouseX: number 11 | mouseY: number 12 | toolColor: string 13 | } 14 | export type Room = { 15 | users: User[] 16 | filename: string 17 | s3Dir: string 18 | pdfUrl: string 19 | pageNum: number 20 | pages: string[] 21 | presenterID: string 22 | zoom: number 23 | scrollLeft: number 24 | scrollTop: number 25 | } 26 | 27 | // Maps roomID to Room 28 | export type RoomsMap = { 29 | [id: string]: Room 30 | } 31 | 32 | // Maps user ID to room ID 33 | export type UsersMap = { 34 | [userID: string]: string 35 | } 36 | 37 | //////////////////////////// 38 | // Client -> Server types // 39 | //////////////////////////// 40 | 41 | export interface RoomData { 42 | roomID: string 43 | } 44 | 45 | export interface JoinRoomData extends RoomData { 46 | toolColor: string 47 | } 48 | 49 | export interface CreateRoomData extends RoomData { 50 | hostID: string 51 | s3Dir: string 52 | pages: string[] 53 | } 54 | 55 | export interface ChangePageData extends RoomData { 56 | pageNum: number 57 | } 58 | 59 | export interface MouseMoveData extends RoomData { 60 | mouseX: number 61 | mouseY: number 62 | } 63 | 64 | export interface ToolColorChangeData extends RoomData { 65 | toolColor: string 66 | } 67 | 68 | export interface ChangeScrollData extends RoomData { 69 | scrollLeft: number 70 | scrollTop: number 71 | } 72 | 73 | export interface ChangeZoomData extends RoomData { 74 | zoom: number 75 | } 76 | 77 | //////////////////////////// 78 | // Server -> Client types // 79 | //////////////////////////// 80 | 81 | export type SyncDocData = { 82 | userID: string 83 | pdfUrl: string 84 | pages: string[] 85 | filename: string 86 | presenterID: string 87 | zoom: number 88 | scrollLeft: number 89 | scrollTop: number 90 | } 91 | 92 | export type SyncPageData = { 93 | pageNum: number 94 | } 95 | 96 | export type UsersData = { 97 | users: User[] 98 | presenterID?: string 99 | } 100 | 101 | export type PresenterData = { 102 | presenterID: string 103 | } 104 | 105 | export type SyncScrollData = { 106 | scrollLeft: number 107 | scrollTop: number 108 | } 109 | 110 | export type SyncZoomData = { 111 | zoom: number 112 | } 113 | -------------------------------------------------------------------------------- /backend/src/utils/createRoom.ts: -------------------------------------------------------------------------------- 1 | import { Room } from "../sockets/types" 2 | 3 | export default ( 4 | filename: string, 5 | s3Dir: string, 6 | pdfUrl: string, 7 | pages: string[] 8 | ): Room => { 9 | return { 10 | users: [], 11 | filename, 12 | s3Dir, 13 | pdfUrl, 14 | pageNum: 1, 15 | pages, 16 | presenterID: "", 17 | zoom: 1, 18 | scrollLeft: 0.5, 19 | scrollTop: 0.5, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/utils/s3.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from "aws-sdk" 2 | 3 | const s3 = new S3({ 4 | accessKeyId: process.env.S3_ACCESS_KEY_ID, 5 | secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, 6 | }) 7 | 8 | export default s3 9 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Beam Me Up, Scotty! 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react" 2 | import { HashRouter as Router, Route } from "react-router-dom" 3 | import { hot } from "react-hot-loader/root" 4 | 5 | import Home from "./Home" 6 | import Room from "./Room" 7 | import TestRoutes from "./TestRoutes" 8 | 9 | const App: React.FC<{}> = (): ReactElement => { 10 | return ( 11 | 12 | 13 | } 17 | /> 18 | {window.location.hostname === "localhost" ? : null} 19 | 20 | ) 21 | } 22 | 23 | export default hot(App) 24 | -------------------------------------------------------------------------------- /frontend/src/components/BeamingModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from "react" 2 | 3 | import { Island, ModalCover, Bold } from "../globalStyles" 4 | import { 5 | Filename, 6 | ErrorButton, 7 | ButtonsContainer, 8 | LoadingIcon, 9 | Message, 10 | ErrorMessage, 11 | } from "./styles" 12 | 13 | type PropTypes = { 14 | filename: string 15 | error?: string 16 | message?: string 17 | handleTryAgain: () => void 18 | } 19 | 20 | const BeamingModal: FC = ({ 21 | filename, 22 | error, 23 | message, 24 | handleTryAgain, 25 | }): ReactElement => { 26 | const renderModalContents = (): ReactElement => { 27 | if (error) { 28 | return ( 29 | <> 30 | {error} 31 | 32 | Try again 33 | 34 | 35 | ) 36 | } else if (message) { 37 | return ( 38 | <> 39 | 40 | {message} 41 | 42 | ) 43 | } 44 | } 45 | 46 | return ( 47 | 48 | 49 | 50 | Beaming {filename} 51 | 52 | {renderModalContents()} 53 | 54 | 55 | ) 56 | } 57 | 58 | export default BeamingModal 59 | -------------------------------------------------------------------------------- /frontend/src/components/BeamingModal/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { H3, Code, Button, COLORS } from "../globalStyles" 4 | 5 | export const Filename = styled(H3)` 6 | margin: 0; 7 | text-align: center; 8 | ` 9 | 10 | export const ErrorButton = styled(Button)` 11 | background-color: ${COLORS.RED}; 12 | flex: 1; 13 | 14 | &:hover { 15 | background-color: ${COLORS.MID_RED}; 16 | box-shadow: 0 0 0.5rem ${COLORS.MID_RED}; 17 | } 18 | 19 | &:active { 20 | background-color: ${COLORS.DARK_RED}; 21 | box-shadow: 0 0 0.5rem ${COLORS.DARK_RED}; 22 | } 23 | ` 24 | 25 | export const ButtonsContainer = styled.div` 26 | width: 100%; 27 | display: flex; 28 | flex: 1; 29 | ` 30 | 31 | export const Message = styled(Code)` 32 | text-align: center; 33 | margin: 0; 34 | ` 35 | 36 | export const ErrorMessage = styled(Code)` 37 | color: ${COLORS.RED}; 38 | text-align: center; 39 | margin: 2.75rem auto; 40 | font-weight: 500; 41 | ` 42 | export const LoadingIcon = styled.img` 43 | width: 4rem; 44 | height: 4rem; 45 | display: block; 46 | margin: 2rem auto; 47 | ` 48 | -------------------------------------------------------------------------------- /frontend/src/components/DocumentView/hooks/usePanhandler.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, MouseEvent, RefObject } from "react" 2 | import { useSelector, useDispatch } from "react-redux" 3 | 4 | import { setScrollRatios } from "../../../store/actions" 5 | import { RootState } from "../../../store/types" 6 | 7 | type UsePanhandlerReturn = { 8 | mouseDown: boolean 9 | handleContextMenu: (ev: MouseEvent) => void 10 | handleMouseDown: (ev: MouseEvent) => void 11 | handleMouseReset: () => void 12 | handlePan: (ev: MouseEvent) => void 13 | } 14 | 15 | export default ( 16 | docRef: RefObject, 17 | socketUpdateScroll: (left: number, top: number) => void 18 | ): UsePanhandlerReturn => { 19 | const dispatch = useDispatch() 20 | const zoomLevel = useSelector((state: RootState) => state.zoom.zoomLevel) 21 | const presenterMode = useSelector( 22 | (state: RootState) => !!state.room.presenterID 23 | ) 24 | const isPresenter = useSelector( 25 | (state: RootState) => state.room.userID === state.room.presenterID 26 | ) 27 | const scrollLeftRatio = useSelector( 28 | (state: RootState) => state.zoom.scrollLeftRatio 29 | ) 30 | const scrollTopRatio = useSelector( 31 | (state: RootState) => state.zoom.scrollTopRatio 32 | ) 33 | 34 | const handleContextMenu = (ev: MouseEvent): void => { 35 | ev.preventDefault() 36 | } 37 | 38 | const [mouseDown, setMouseDown] = useState(false) 39 | const [startX, setStartX] = useState(0) 40 | const [startY, setStartY] = useState(0) 41 | const handleMouseDown = (ev: MouseEvent): void => { 42 | if (zoomLevel <= 1 || (presenterMode && !isPresenter)) return 43 | setMouseDown(true) 44 | setStartX(ev.clientX) 45 | setStartY(ev.clientY) 46 | } 47 | 48 | const handleMouseReset = (): void => { 49 | if (mouseDown) { 50 | setMouseDown(false) 51 | setStartX(0) 52 | setStartY(0) 53 | } 54 | } 55 | 56 | // default is center (0.5 of width and height) 57 | const handlePan = (ev: MouseEvent): void => { 58 | if (!mouseDown || zoomLevel <= 1) return 59 | 60 | const containerElmt = docRef.current 61 | 62 | let deltaX = startX - ev.clientX 63 | let deltaY = startY - ev.clientY 64 | 65 | containerElmt.scroll( 66 | containerElmt.scrollLeft + deltaX, 67 | containerElmt.scrollTop + deltaY 68 | ) 69 | 70 | setStartX(ev.clientX) 71 | setStartY(ev.clientY) 72 | } 73 | 74 | const updateScrollPositions = (): void => { 75 | const { 76 | scrollWidth, 77 | clientWidth, 78 | scrollHeight, 79 | clientHeight, 80 | } = docRef.current 81 | const scrollLeftMax = scrollWidth - clientWidth 82 | const scrollTopMax = scrollHeight - clientHeight 83 | 84 | docRef.current.scrollLeft = scrollLeftMax * scrollLeftRatio 85 | docRef.current.scrollTop = scrollTopMax * scrollTopRatio 86 | } 87 | 88 | // Recenter view on zoom 89 | useEffect(() => { 90 | if (zoomLevel === 1) { 91 | const broadcast = presenterMode && isPresenter 92 | const defaultScrollRatio = 0.5 93 | 94 | dispatch(setScrollRatios(defaultScrollRatio, defaultScrollRatio)) 95 | if (broadcast) { 96 | socketUpdateScroll(defaultScrollRatio, defaultScrollRatio) 97 | } 98 | } 99 | updateScrollPositions() 100 | }, [zoomLevel]) 101 | 102 | // Update view when in presenter mode & is not presenter 103 | useEffect(() => { 104 | if (presenterMode && !isPresenter) { 105 | updateScrollPositions() 106 | } 107 | }, [scrollLeftRatio, scrollTopRatio]) 108 | 109 | return { 110 | mouseDown, 111 | handleContextMenu, 112 | handleMouseDown, 113 | handleMouseReset, 114 | handlePan, 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /frontend/src/components/DocumentView/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | ReactElement, 4 | RefObject, 5 | useRef, 6 | useState, 7 | useEffect, 8 | } from "react" 9 | import { useSelector, useDispatch } from "react-redux" 10 | 11 | import { DocumentContainer, PageContainer, Page } from "./styles" 12 | import usePanhandler from "./hooks/usePanhandler" 13 | import { cachePage, setScrollRatios } from "../../store/actions" 14 | import { RootState } from "../../store/types" 15 | 16 | type PropTypes = { 17 | pageRef: RefObject 18 | socketUpdateScroll(left: number, top: number): void 19 | } 20 | 21 | const View: FC = ({ pageRef, socketUpdateScroll }): ReactElement => { 22 | const docRef = useRef(null) 23 | const dispatch = useDispatch() 24 | const zoomLevel = useSelector((state: RootState) => state.zoom.zoomLevel) 25 | const pdfUrl = useSelector((state: RootState) => state.room.pdfUrl) 26 | const pageUrl = useSelector( 27 | (state: RootState) => 28 | `${state.room.pdfUrl}/${state.pages.pages[state.pages.currentPage - 1]}` 29 | ) 30 | const cachedPages = useSelector((state: RootState) => state.pages.cached) 31 | 32 | const nextPageFile = useSelector( 33 | (state: RootState) => 34 | state.pages.pages[state.pages.currentPage + state.pages.currentPage - 1] 35 | ) 36 | const nextNextPageFile = useSelector( 37 | (state: RootState) => 38 | state.pages.pages[state.pages.currentPage + state.pages.currentPage] 39 | ) 40 | 41 | const presenterMode = useSelector( 42 | (state: RootState) => !!state.room.presenterID 43 | ) 44 | const isPresenter = useSelector( 45 | (state: RootState) => state.room.userID === state.room.presenterID 46 | ) 47 | 48 | const { 49 | mouseDown, 50 | handleContextMenu, 51 | handleMouseDown, 52 | handleMouseReset, 53 | handlePan, 54 | } = usePanhandler(docRef, socketUpdateScroll) 55 | 56 | useEffect(() => { 57 | // Load next 2 pages to be loaded if not cached yet 58 | // on p1 -> load 2, 3 59 | // on p2 -> load 4, 5 60 | // on p3 -> load 6, 7, etc. 61 | 62 | if (nextPageFile && !cachedPages[nextPageFile]) { 63 | const nextPage = new Image() 64 | nextPage.src = `${pdfUrl}/${nextPageFile}` 65 | dispatch(cachePage(nextPageFile)) 66 | } 67 | 68 | if (nextNextPageFile && !cachedPages[nextNextPageFile]) { 69 | const nextNextPage = new Image() 70 | nextNextPage.src = `${pdfUrl}/${nextNextPageFile}` 71 | dispatch(cachePage(nextNextPageFile)) 72 | } 73 | }, [pdfUrl, nextPageFile, nextNextPageFile]) 74 | 75 | const [showScrollbars, setShowScrollbars] = useState(true) 76 | useEffect(() => { 77 | // Show scrollbars only if not in presenter mode 78 | // OR if in presenter mode and is presenter 79 | // AND zoom level is more than 1 80 | if ((!presenterMode || (presenterMode && isPresenter)) && zoomLevel > 1) { 81 | setShowScrollbars(true) 82 | } else { 83 | setShowScrollbars(false) 84 | } 85 | }, [zoomLevel, presenterMode, isPresenter]) 86 | 87 | const handleScroll = (ev): void => { 88 | const { 89 | scrollLeft, 90 | scrollWidth, 91 | clientWidth, 92 | scrollTop, 93 | scrollHeight, 94 | clientHeight, 95 | } = ev.target 96 | 97 | const scrollLeftMax = scrollWidth - clientWidth 98 | const scrollTopMax = scrollHeight - clientHeight 99 | 100 | // Default to 0.5 (center) 101 | const left = scrollLeftMax ? scrollLeft / scrollLeftMax : 0.5 102 | const top = scrollTopMax ? scrollTop / scrollTopMax : 0.5 103 | 104 | dispatch(setScrollRatios(left, top)) 105 | if (presenterMode && isPresenter) { 106 | socketUpdateScroll(left, top) 107 | } 108 | } 109 | 110 | return ( 111 | 116 | 126 | 127 | 128 | 129 | ) 130 | } 131 | 132 | export default View 133 | -------------------------------------------------------------------------------- /frontend/src/components/DocumentView/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | type DocumentContainerProps = { 4 | showScrollbars: boolean 5 | } 6 | 7 | export const DocumentContainer = styled.div` 8 | margin: 3rem auto 0 auto; 9 | height: calc(100vh - 3rem); 10 | width: 100vw; 11 | overflow: ${(props): string => (props.showScrollbars ? "scroll" : "hidden")}; 12 | ` 13 | 14 | type PageContainerProp = { 15 | disablePan: boolean 16 | scale: number 17 | mouseDown: boolean 18 | } 19 | 20 | export const PageContainer = styled.div.attrs((props: PageContainerProp) => { 21 | const transform = `scale(${props.scale})` 22 | 23 | let cursor = "default" 24 | if (props.scale > 1 && !props.disablePan) { 25 | cursor = props.mouseDown ? "grabbing" : "grab" 26 | } 27 | 28 | return { 29 | style: { 30 | cursor, 31 | transform, 32 | }, 33 | } 34 | })` 35 | box-sizing: border-box; 36 | margin: auto; 37 | padding: 2rem; 38 | height: 100%; 39 | display: flex; 40 | transform-origin: top left; 41 | ` 42 | 43 | export const Page = styled.img` 44 | margin: auto; 45 | max-width: 100%; 46 | max-height: 100%; 47 | object-fit: contain; 48 | background-color: white; 49 | box-shadow: 0 0 1rem rgba(0, 0, 0, 0.25); 50 | ` 51 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Prompt/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from "react" 2 | 3 | import { Container, PdfFilename, ReadyMessage } from "./styles" 4 | 5 | type PropTypes = { 6 | mode: string 7 | filename: string 8 | } 9 | 10 | const Prompt: FC = ({ mode, filename }): ReactElement => { 11 | const renderMessage = (): ReactElement => { 12 | switch (mode) { 13 | case "select": 14 | return ( 15 | <> 16 | To get started 17 | select a PDF file 18 | 19 | ) 20 | case "upload": 21 | return ( 22 | <> 23 | Ready to beam 24 | {filename || "filename placeholder"} 25 | 26 | ) 27 | default: 28 | return null 29 | } 30 | } 31 | 32 | return {renderMessage()} 33 | } 34 | 35 | export default Prompt 36 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Prompt/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { H2, H3, COLORS } from "../../globalStyles" 4 | 5 | export const Container = styled.div` 6 | text-align: center; 7 | margin: -2.5rem 0 2rem 0; 8 | ` 9 | 10 | export const PdfFilename = styled(H2)` 11 | color: ${COLORS.WHITE}; 12 | margin: 0; 13 | ` 14 | 15 | export const ReadyMessage = styled(H3)` 16 | color: ${COLORS.WHITE}; 17 | margin: 0; 18 | ` 19 | -------------------------------------------------------------------------------- /frontend/src/components/Home/SelectPDF/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, ChangeEvent } from "react" 2 | 3 | import { Label, Input, Icon, IconWrapper } from "./styles" 4 | 5 | type PropType = { 6 | handleFile(e: ChangeEvent): void 7 | pdfFile: File 8 | } 9 | 10 | const SelectPDF: FC = ({ handleFile }): ReactElement => { 11 | return ( 12 | <> 13 | 19 | 24 | 25 | ) 26 | } 27 | 28 | export default SelectPDF 29 | -------------------------------------------------------------------------------- /frontend/src/components/Home/SelectPDF/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | import { COLORS, ANIMATION_DURATION } from "../../globalStyles" 3 | 4 | // Effectively hiding input 5 | export const Input = styled.input` 6 | width: 0.01px; 7 | height: 0.01px; 8 | opacity: 0; 9 | position: absolute; 10 | z-index: -999; 11 | 12 | &:focus + label { 13 | outline: 1px dotted black; 14 | outline: -webkit-focus-ring-color auto 5px; 15 | } 16 | 17 | &:active + label { 18 | border: 4px solid ${COLORS.MID_GRAY}; 19 | } 20 | ` 21 | 22 | export const Label = styled.label` 23 | box-sizing: border-box; 24 | margin: auto; 25 | background-color: ${COLORS.SPACE_GRAY}; 26 | width: 4rem; 27 | height: 4rem; 28 | border-radius: 4rem; 29 | text-align: center; 30 | cursor: pointer; 31 | -webkit-touch-callout: none; 32 | -webkit-user-select: none; 33 | -moz-user-select: none; 34 | -ms-user-select: none; 35 | user-select: none; 36 | display: flex; 37 | transition: box-shadow ${ANIMATION_DURATION} ease; 38 | border: 4px solid ${COLORS.WHITE}; 39 | 40 | &:hover { 41 | box-shadow: 0 0 0.5rem ${COLORS.WHITE}; 42 | } 43 | ` 44 | 45 | export const Icon = styled.div` 46 | width: 3rem; 47 | height: 3rem; 48 | margin-top: 1px; 49 | background-image: url(/static/icons/folder.svg); 50 | background-repeat: no-repeat; 51 | background-position: center; 52 | 53 | &:active { 54 | background-image: url(/static/icons/folderDark.svg); 55 | } 56 | ` 57 | 58 | export const IconWrapper = styled.div` 59 | margin: auto; 60 | ` 61 | -------------------------------------------------------------------------------- /frontend/src/components/Home/UploadPDF/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, MouseEvent } from "react" 2 | import { UploadButton, UploadButtonText } from "./styles" 3 | 4 | type PropTypes = { 5 | disabled: boolean 6 | inUploadMode: boolean 7 | handleUpload(e: MouseEvent): Promise 8 | } 9 | 10 | const UploadPDF: FC = ({ 11 | disabled, 12 | handleUpload, 13 | inUploadMode, 14 | }): ReactElement => { 15 | return ( 16 | 21 | 22 | Beam me up, Scotty! 23 | 24 | 25 | ) 26 | } 27 | 28 | export default UploadPDF 29 | -------------------------------------------------------------------------------- /frontend/src/components/Home/UploadPDF/keyframes.ts: -------------------------------------------------------------------------------- 1 | import { keyframes } from "styled-components" 2 | 3 | export const buttonExpand = keyframes` 4 | 0% { 5 | width: 4rem; 6 | } 7 | 8 | 100% { 9 | width: 22.5%; 10 | min-width: 225px; 11 | max-width: 325px; 12 | } 13 | ` 14 | 15 | export const textAppear = keyframes` 16 | 0%{ 17 | opacity: 0; 18 | } 19 | 20 | 50% { 21 | opacity: 0; 22 | } 23 | 24 | 100% { 25 | opacity: 1; 26 | } 27 | ` 28 | 29 | export const buttonReset = keyframes` 30 | 0% { 31 | width: 22.5%; 32 | min-width: 225px; 33 | max-width: 325px; 34 | } 35 | ` 36 | 37 | export const textReset = keyframes` 38 | 0% { 39 | opacity: 1; 40 | } 41 | 42 | 50% { 43 | opacity: 0; 44 | } 45 | ` 46 | -------------------------------------------------------------------------------- /frontend/src/components/Home/UploadPDF/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { Keyframes } from "styled-components" 2 | import { ButtonRound, COLORS, ANIMATION_DURATION } from "../../globalStyles" 3 | import { buttonExpand, textAppear, buttonReset, textReset } from "./keyframes" 4 | 5 | type UploadProps = { 6 | inUploadMode: boolean 7 | } 8 | 9 | export const UploadButton = styled(ButtonRound)` 10 | animation: ${(props): Keyframes => 11 | props.inUploadMode ? buttonExpand : buttonReset} 12 | ${ANIMATION_DURATION} ease-in-out forwards; 13 | border: 4px solid ${COLORS.WHITE}; 14 | 15 | &:hover { 16 | background-color: ${COLORS.SPACE_GRAY}; 17 | box-shadow: 0 0 0.5rem ${COLORS.WHITE}; 18 | } 19 | 20 | &:active { 21 | color: ${COLORS.MID_GRAY}; 22 | border-color: ${COLORS.MID_GRAY}; 23 | background-color: ${COLORS.SPACE_GRAY}; 24 | } 25 | 26 | &:disabled { 27 | border: 0; 28 | color: ${COLORS.DARK_GRAY}; 29 | background-color: ${COLORS.MID_GRAY}; 30 | } 31 | ` 32 | 33 | export const UploadButtonText = styled.span` 34 | animation: ${(props): Keyframes => 35 | props.inUploadMode ? textAppear : textReset} 36 | ${ANIMATION_DURATION} ease-in-out forwards; 37 | opacity: 0; 38 | ` 39 | -------------------------------------------------------------------------------- /frontend/src/components/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useEffect, 4 | ChangeEvent, 5 | ReactElement, 6 | MouseEvent, 7 | } from "react" 8 | import axios from "axios" 9 | import { Redirect } from "react-router-dom" 10 | import { v4 } from "uuid" 11 | 12 | import socket from "../../socket" 13 | import { RoomData } from "../../../../backend/src/sockets/types" 14 | import { conveyorAPI, pingbackAddress } from "../../utils/apis" 15 | 16 | import BeamingModal from "../BeamingModal" 17 | import SelectPDF from "./SelectPDF" 18 | import UploadPDF from "./UploadPDF" 19 | import Prompt from "./Prompt" 20 | 21 | import { Background, COLORS } from "../globalStyles" 22 | import { Form, ResetText } from "./styles" 23 | 24 | export type LocationState = { 25 | host: boolean 26 | } 27 | 28 | const Home: React.FC<{}> = (): ReactElement => { 29 | const [pdfFile, setPdfFile] = useState(null) 30 | const [mode, setMode] = useState("select") 31 | const handleFile = (e: ChangeEvent): void => { 32 | setPdfFile(e.target.files[0]) 33 | setMode("upload") 34 | } 35 | 36 | const handleFormReset = (): void => { 37 | setMode("select") 38 | 39 | // Wait until CSS animaiton is under way 40 | setTimeout(() => { 41 | setPdfFile(null) 42 | }, 250) 43 | } 44 | 45 | const [roomID, setRoomID] = useState("") 46 | const [loading, setLoading] = useState(false) 47 | const handleUpload = async ( 48 | e: MouseEvent 49 | ): Promise => { 50 | e.preventDefault() 51 | 52 | if (!pdfFile) { 53 | return 54 | } 55 | 56 | setLoading(true) 57 | 58 | const uuidv4 = v4() 59 | const { type } = pdfFile 60 | 61 | try { 62 | const { message } = ( 63 | await axios.post(`${conveyorAPI}/convert/pdf?out=jpg`, pdfFile, { 64 | headers: { 65 | "Content-Type": type, 66 | "x-Pingback": pingbackAddress, 67 | "x-Forward-Data": JSON.stringify({ 68 | filename: pdfFile.name, 69 | hostID: socket.id, 70 | roomID: uuidv4, 71 | }), 72 | }, 73 | }) 74 | ).data 75 | 76 | if (!message) { 77 | console.error("Conveyor error: no message received") 78 | } 79 | } catch (err) { 80 | console.error(`Conveyor error: ${err.message}`) 81 | } 82 | } 83 | 84 | const [conveyorMessage, setConveyorMessage] = useState("") 85 | const [conveyorError, setConveyorError] = useState("") 86 | useEffect(() => { 87 | const pingConveyor = async (): Promise => { 88 | try { 89 | // Ping conveyor to make sure it's awake 90 | const { message } = (await axios.get(`${conveyorAPI}/ping`)).data 91 | console.log(`Conveyor status: ${message}`) 92 | } catch (err) { 93 | console.error("Conveyor not awake") 94 | } 95 | } 96 | 97 | pingConveyor() 98 | 99 | socket.on("room created", (data: RoomData) => { 100 | setRoomID(data.roomID) 101 | }) 102 | 103 | socket.on("conveyor update", (data: string) => { 104 | setConveyorMessage(data) 105 | }) 106 | 107 | socket.on("conveyor error", (data: string) => { 108 | setConveyorError(data) 109 | }) 110 | 111 | return (): void => { 112 | socket.off("room created") 113 | socket.off("conveyor update") 114 | socket.off("conveyor error") 115 | } 116 | }, []) 117 | 118 | const handleTryAgain = (): void => { 119 | setLoading(false) 120 | setConveyorMessage("") 121 | setConveyorError("") 122 | } 123 | 124 | const renderHome = (): ReactElement => { 125 | return ( 126 | <> 127 |
128 | 129 | {!pdfFile ? ( 130 | 131 | ) : ( 132 | 137 | )} 138 | 139 | Select a different file 140 | 141 | 142 | {loading && (conveyorMessage || conveyorError) ? ( 143 | 149 | ) : null} 150 | 151 | ) 152 | } 153 | 154 | const redirectToRoom = (): ReactElement => { 155 | return ( 156 | 162 | ) 163 | } 164 | 165 | return ( 166 | 167 | {roomID ? redirectToRoom() : renderHome()} 168 | 169 | ) 170 | } 171 | 172 | export default Home 173 | -------------------------------------------------------------------------------- /frontend/src/components/Home/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { COLORS, P } from "../globalStyles" 4 | 5 | export const Form = styled.form` 6 | width: 100%; 7 | min-width: 500px; 8 | margin: auto; 9 | display: flex; 10 | flex-direction: column; 11 | font-family: sans-serif; 12 | ` 13 | 14 | type ResetTextProps = { 15 | show: boolean 16 | } 17 | 18 | export const ResetText = styled(P)` 19 | opacity: ${(props): number => (props.show ? 1 : 0)}; 20 | text-align: center; 21 | color: ${COLORS.MID_GRAY}; 22 | cursor: ${(props): string => (props.show ? "pointer" : "auto")}; 23 | margin-top: 1.5rem; 24 | text-decoration: underline; 25 | -webkit-touch-callout: none; 26 | -webkit-user-select: none; 27 | -khtml-user-select: none; 28 | -moz-user-select: none; 29 | -ms-user-select: none; 30 | user-select: none; 31 | transition: colors 0.25s ease; 32 | 33 | &:hover { 34 | color: ${COLORS.WHITE}; 35 | } 36 | ` 37 | -------------------------------------------------------------------------------- /frontend/src/components/LinkModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, FC, useRef, useEffect, useState } from "react" 2 | 3 | import { Island, ModalCover } from "../globalStyles" 4 | import { 5 | LinkInput, 6 | InputContainer, 7 | OKButton, 8 | Title, 9 | Body, 10 | CopyButton, 11 | } from "./styles" 12 | 13 | type PropTypes = { 14 | link: string 15 | } 16 | 17 | const LinkModal: FC = ({ link }): ReactElement => { 18 | const [show, setShow] = useState(true) 19 | const handleOK = (): void => { 20 | setShow(false) 21 | } 22 | 23 | const inputRef = useRef(null) 24 | useEffect(() => { 25 | if (inputRef.current) { 26 | inputRef.current.focus() 27 | } 28 | }, []) 29 | 30 | const handleFocus = (): void => { 31 | inputRef.current.select() 32 | } 33 | 34 | const handleCopy = (): void => { 35 | inputRef.current.select() 36 | document.execCommand("copy") 37 | } 38 | 39 | const renderModal = (): ReactElement => { 40 | return ( 41 | 42 | 43 | Greetings Earthling 44 | 45 | You’re all set! Copy and share the following link to invite others 46 | to view this document. 47 | 48 | 49 | 50 | 56 | 64 | 65 | 66 | OK 67 | 68 | 69 | ) 70 | } 71 | 72 | return show ? renderModal() : null 73 | } 74 | 75 | export default LinkModal 76 | -------------------------------------------------------------------------------- /frontend/src/components/LinkModal/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { H2, P, Button, ToolButton, Input, COLORS } from "../globalStyles" 4 | 5 | export const InputContainer = styled.div` 6 | width: 100%; 7 | margin: 1.25rem 0.5rem 1.75rem 0; 8 | position: relative; 9 | height: 2rem; 10 | ` 11 | 12 | export const LinkInput = styled(Input)` 13 | width: calc(100% - 2.25rem - 0.5rem); 14 | height: 2.25rem; 15 | margin-right: 0.5rem; 16 | font-size: 0.9rem; 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | ` 21 | 22 | export const CopyButton = styled(ToolButton)` 23 | box-sizing: border-box; 24 | border: 1px solid ${COLORS.MID_GRAY}; 25 | position: absolute; 26 | top: 0; 27 | right: 0; 28 | ` 29 | 30 | export const OKButton = styled(Button)` 31 | width: 100%; 32 | ` 33 | 34 | export const Title = styled(H2)` 35 | margin: 0; 36 | text-align: center; 37 | ` 38 | 39 | export const Body = styled(P)` 40 | margin: 1.125rem 0 0 0; 41 | ` 42 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from "react" 2 | 3 | import { LoadingCover, LoadingIcon } from "./styles" 4 | 5 | const Loading: FC<{}> = (): ReactElement => { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export default Loading 14 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { Cover } from "../globalStyles" 4 | 5 | export const LoadingCover = styled(Cover)` 6 | width: 100%; 7 | height: 100%; 8 | ` 9 | 10 | export const LoadingIcon = styled.img` 11 | width: 6rem; 12 | height: 6rem; 13 | margin: auto; 14 | ` 15 | -------------------------------------------------------------------------------- /frontend/src/components/NavBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, ReactElement, FormEvent } from "react" 2 | import { useSelector, useDispatch } from "react-redux" 3 | 4 | import { ToolButton } from "../globalStyles" 5 | import { 6 | NavBarContainer, 7 | NavChild, 8 | InfoText, 9 | PageNumContainer, 10 | PageNumForm, 11 | PageNumInput, 12 | MaxPageNum, 13 | ReverseToolButton, 14 | CloseButton, 15 | } from "./styles" 16 | import { goToPage } from "../../store/actions" 17 | import { RootState } from "../../store/types" 18 | 19 | type PropTypes = { 20 | handleClose(): void 21 | socketChangePage: (pageNum: number) => void 22 | } 23 | 24 | const NavBar: React.FC = ({ 25 | handleClose, 26 | socketChangePage, 27 | }): ReactElement => { 28 | const dispatch = useDispatch() 29 | 30 | const [displayPageNum, setDisplayPageNum] = useState("") 31 | const maxPage = useSelector((state: RootState) => state.pages.pages.length) 32 | const currentPage = useSelector((state: RootState) => state.pages.currentPage) 33 | const filename = useSelector((state: RootState) => state.room.filename) 34 | const numOfUsers = useSelector((state: RootState) => state.room.users.length) 35 | const inputDisabled = useSelector( 36 | (state: RootState) => 37 | state.room.presenterID && state.room.presenterID !== state.room.userID 38 | ) 39 | 40 | const dispatchPageNum = (pageNum: number): void => { 41 | dispatch(goToPage(pageNum)) 42 | socketChangePage(pageNum) 43 | } 44 | 45 | const handleInputSubmit = (ev: FormEvent): void => { 46 | ev.preventDefault() 47 | if (!displayPageNum || inputDisabled) return 48 | 49 | const pageNum = parseInt(displayPageNum, 10) 50 | dispatchPageNum(pageNum) 51 | } 52 | 53 | const handleInputChange = (ev: FormEvent): void => { 54 | if (!ev.currentTarget.value) { 55 | setDisplayPageNum("") 56 | return 57 | } 58 | 59 | const parsedPageNum = parseInt(ev.currentTarget.value, 10) 60 | if (!isNaN(parsedPageNum)) { 61 | setDisplayPageNum(ev.currentTarget.value) 62 | } 63 | } 64 | 65 | const goFirstPage = (): void => { 66 | dispatchPageNum(1) 67 | } 68 | 69 | const goLastPage = (): void => { 70 | dispatchPageNum(maxPage) 71 | } 72 | 73 | const goPrevPage = (): void => { 74 | const newPage = currentPage - 1 75 | if (newPage > 0) { 76 | dispatchPageNum(newPage) 77 | } 78 | } 79 | 80 | const goNextPage = (): void => { 81 | const newPage = currentPage + 1 82 | if (newPage <= maxPage) { 83 | dispatchPageNum(newPage) 84 | } 85 | } 86 | 87 | useEffect(() => { 88 | setDisplayPageNum(currentPage.toString()) 89 | }, [currentPage]) 90 | 91 | return ( 92 | 93 | 94 | {filename} 95 | 96 | 97 | 98 | 107 | 116 | 117 | 118 | 119 | 125 | 126 | / {maxPage} 127 | 128 | 129 | 138 | 147 | 148 | 149 | 150 | 151 | {`${numOfUsers} ${numOfUsers > 1 ? "users" : "user"}`} 152 | 153 | 161 | 162 | 163 | ) 164 | } 165 | 166 | export default NavBar 167 | -------------------------------------------------------------------------------- /frontend/src/components/NavBar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | import { ToolButton, H4, Input, COLORS } from "../globalStyles" 3 | 4 | export const NavBarContainer = styled.div` 5 | width: 100%; 6 | height: 3rem; 7 | background-color: ${COLORS.LIGHT_GRAY}; 8 | display: flex; 9 | box-shadow: 0rem 0rem 0.5rem rgba(0, 0, 0, 0.25); 10 | position: fixed; 11 | z-index: 1000; 12 | ` 13 | 14 | type NavChildProps = { 15 | flex?: string 16 | } 17 | 18 | export const NavChild = styled.div` 19 | display: flex; 20 | 21 | ${(props): any => 22 | props.flex && 23 | css` 24 | flex: ${props.flex}; 25 | `} 26 | ` 27 | 28 | export const InfoText = styled(H4)` 29 | margin: auto; 30 | display: inline-block; 31 | ` 32 | 33 | export const PageNumContainer = styled.div` 34 | margin: auto 0.5rem; 35 | min-width: 90px; 36 | max-width: 120px; 37 | ` 38 | 39 | export const PageNumForm = styled.form` 40 | display: inline; 41 | ` 42 | 43 | export const PageNumInput = styled(Input)` 44 | box-sizing: border-box; 45 | width: 48%; 46 | font-weight: 600; 47 | text-align: right; 48 | margin-right: 4%; 49 | ` 50 | 51 | export const MaxPageNum = styled(InfoText)` 52 | font-weight: 400; 53 | ` 54 | 55 | export const ReverseToolButton = styled(ToolButton)` 56 | transform: rotate(180deg); 57 | ` 58 | 59 | export const CloseButton = styled(ToolButton)` 60 | &:hover:enabled { 61 | background-color: ${COLORS.RED}; 62 | } 63 | 64 | &:active:enabled { 65 | background-color: ${COLORS.MID_RED}; 66 | } 67 | ` 68 | -------------------------------------------------------------------------------- /frontend/src/components/Pointer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from "react" 2 | 3 | import { PointerDiv } from "./styles" 4 | 5 | export type PropType = { 6 | x: number 7 | y: number 8 | color: string 9 | } 10 | 11 | const Pointer: FC = ({ x, y, color }): ReactElement => { 12 | return 13 | } 14 | 15 | export default Pointer 16 | -------------------------------------------------------------------------------- /frontend/src/components/Pointer/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { PropType } from "./index" 4 | 5 | // Cursor size in px 6 | const SIZE = 8 7 | 8 | export const PointerDiv = styled.div.attrs((props: PropType) => ({ 9 | style: { 10 | top: `${props.y - SIZE / 2}px`, 11 | left: `${props.x - SIZE / 2}px`, 12 | backgroundColor: props.color, 13 | }, 14 | }))` 15 | width: ${SIZE}px; 16 | height: ${SIZE}px; 17 | border-radius: ${SIZE / 2}px; 18 | position: fixed; 19 | z-index: 999; 20 | pointer-events: none; 21 | ` 22 | -------------------------------------------------------------------------------- /frontend/src/components/Room/hooks/index.ts: -------------------------------------------------------------------------------- 1 | /* import usePageNum from "./usePageNum" */ 2 | import usePointer from "./usePointer" 3 | import useSocket from "./useSocket" 4 | 5 | export { usePointer, useSocket } 6 | -------------------------------------------------------------------------------- /frontend/src/components/Room/hooks/usePointer.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, RefObject, Dispatch, SetStateAction } from "react" 2 | 3 | import { MouseMoveData } from "../../../../../backend/src/sockets/types" 4 | import roundTo from "../../../utils/roundTo" 5 | import socket from "../../../socket" 6 | 7 | const roundTo3 = roundTo(3) 8 | 9 | type UsePointerReturn = { 10 | showMouse: boolean 11 | setShowMouse: Dispatch> 12 | ownMouseX: number 13 | ownMouseY: number 14 | } 15 | 16 | export default ( 17 | roomID: string, 18 | pageRef: RefObject 19 | ): UsePointerReturn => { 20 | const [ownMouseX, setOwnMouseX] = useState(0) 21 | const [ownMouseY, setOwnMouseY] = useState(0) 22 | 23 | const handleMouseMove = (ev?: MouseEvent): void => { 24 | if (!pageRef.current) return 25 | 26 | const { 27 | offsetLeft, 28 | clientWidth, 29 | offsetTop, 30 | clientHeight, 31 | offsetParent, 32 | } = pageRef.current 33 | 34 | const container = offsetParent as HTMLDivElement 35 | const mouseX: number = ev 36 | ? roundTo3((ev.clientX - offsetLeft) / clientWidth) 37 | : null 38 | const mouseY: number = ev 39 | ? roundTo3((ev.clientY - offsetTop - container.offsetTop) / clientHeight) 40 | : null 41 | 42 | const mouseMoveData: MouseMoveData = { 43 | roomID, 44 | mouseX, 45 | mouseY, 46 | } 47 | 48 | setOwnMouseX(ev ? roundTo3(ev.clientX) : 0) 49 | setOwnMouseY(ev ? roundTo3(ev.clientY) : 0) 50 | socket.emit("mousemove", mouseMoveData) 51 | } 52 | 53 | const [showMouse, setShowMouse] = useState(false) 54 | useEffect(() => { 55 | if (showMouse) { 56 | document.addEventListener("mousemove", handleMouseMove) 57 | } else { 58 | handleMouseMove() 59 | } 60 | 61 | return (): void => { 62 | document.removeEventListener("mousemove", handleMouseMove) 63 | } 64 | }, [showMouse]) 65 | 66 | return { showMouse, setShowMouse, ownMouseX, ownMouseY } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/Room/hooks/useSocket.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react" 2 | import { useSelector, useDispatch } from "react-redux" 3 | 4 | import { 5 | RoomData, 6 | JoinRoomData, 7 | SyncDocData, 8 | SyncPageData, 9 | UsersData, 10 | ToolColorChangeData, 11 | ChangePageData, 12 | PresenterData, 13 | ChangeScrollData, 14 | SyncScrollData, 15 | ChangeZoomData, 16 | SyncZoomData, 17 | } from "../../../../../backend/src/sockets/types" 18 | import socket from "../../../socket" 19 | import * as actions from "../../../store/actions" 20 | import { RootState } from "../../../store/types" 21 | 22 | type UseSocketReturn = { 23 | error: string 24 | socketChangePage: (pageNum: number) => void 25 | socketUpdatePresenter: () => void 26 | socketUpdateZoom: (zoom: number) => void 27 | socketUpdateScroll: (left: number, top: number) => void 28 | } 29 | 30 | export default (roomID: string): UseSocketReturn => { 31 | const dispatch = useDispatch() 32 | const toolColor = useSelector((state: RootState) => state.tools.color) 33 | 34 | const [error, setError] = useState("") 35 | useEffect(() => { 36 | const joinRoomData: JoinRoomData = { roomID, toolColor } 37 | socket.emit("join room", joinRoomData) 38 | 39 | socket.on("sync document", (data: SyncDocData): void => { 40 | dispatch(actions.setUserID(data.userID)) 41 | dispatch(actions.setPdfUrl(data.pdfUrl)) 42 | dispatch(actions.setPages(data.pages)) 43 | dispatch(actions.setFilename(data.filename)) 44 | dispatch(actions.setPresenter(data.presenterID)) 45 | 46 | if (data.presenterID && data.presenterID !== data.userID) { 47 | dispatch(actions.setZoomLevel(data.zoom)) 48 | dispatch(actions.setScrollRatios(data.scrollLeft, data.scrollTop)) 49 | } 50 | }) 51 | 52 | socket.on("sync page", (data: SyncPageData): void => { 53 | dispatch(actions.goToPage(data.pageNum)) 54 | }) 55 | 56 | socket.on("update users", (data: UsersData): void => { 57 | dispatch(actions.setUsers(data.users)) 58 | 59 | if (typeof data.presenterID !== "undefined") { 60 | dispatch(actions.setPresenter(data.presenterID)) 61 | } 62 | }) 63 | 64 | socket.on("update presenter", (data: PresenterData) => { 65 | dispatch(actions.setPresenter(data.presenterID)) 66 | }) 67 | 68 | socket.on("update zoom", (data: SyncZoomData) => { 69 | dispatch(actions.setZoomLevel(data.zoom)) 70 | }) 71 | 72 | socket.on("update scroll", (data: SyncScrollData) => { 73 | dispatch(actions.setScrollRatios(data.scrollLeft, data.scrollTop)) 74 | }) 75 | 76 | socket.on("error", (data: Error): void => { 77 | setError(data.message) 78 | }) 79 | 80 | return (): void => { 81 | socket.off("sync document") 82 | socket.off("sync page") 83 | socket.off("update users") 84 | socket.off("update presenter") 85 | socket.off("update zoom") 86 | socket.off("update scroll") 87 | socket.off("error") 88 | } 89 | }, []) 90 | 91 | useEffect(() => { 92 | if (toolColor) { 93 | const data: ToolColorChangeData = { roomID, toolColor } 94 | socket.emit("change tool color", data) 95 | } 96 | }, [toolColor]) 97 | 98 | const socketChangePage = (pageNum: number): void => { 99 | const data: ChangePageData = { roomID, pageNum } 100 | socket.emit("client change page", data) 101 | } 102 | 103 | const socketUpdatePresenter = (): void => { 104 | const data: RoomData = { roomID } 105 | socket.emit("client update presenter", data) 106 | } 107 | 108 | const socketUpdateZoom = (zoom: number): void => { 109 | const data: ChangeZoomData = { roomID, zoom } 110 | socket.emit("client update zoom", data) 111 | } 112 | 113 | const socketUpdateScroll = (scrollLeft: number, scrollTop: number): void => { 114 | const data: ChangeScrollData = { roomID, scrollLeft, scrollTop } 115 | socket.emit("client update scroll", data) 116 | } 117 | 118 | return { 119 | error, 120 | socketChangePage, 121 | socketUpdatePresenter, 122 | socketUpdateZoom, 123 | socketUpdateScroll, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /frontend/src/components/Room/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useRef, useEffect } from "react" 2 | import { RouteComponentProps, useHistory, withRouter } from "react-router-dom" 3 | import { useSelector, useDispatch } from "react-redux" 4 | import randomColor from "randomcolor" 5 | 6 | // Utils, etc. 7 | import { RoomData } from "../../../../backend/src/sockets/types" 8 | import socket from "../../socket" 9 | 10 | // Components 11 | import NavBar from "../NavBar" 12 | import Pointer from "../Pointer" 13 | import { LocationState } from "../Home" 14 | import LinkModal from "../LinkModal" 15 | import DocumentView from "../DocumentView" 16 | import ZoomBar from "../ZoomBar" 17 | import ToolBar from "../ToolBar" 18 | 19 | import { Background, COLORS } from "../globalStyles" 20 | import { usePointer, useSocket } from "./hooks" 21 | import * as actions from "../../store/actions" 22 | import { RootState } from "../../store/types" 23 | 24 | interface PropTypes extends RouteComponentProps { 25 | id: string 26 | } 27 | 28 | const Room: React.FC = ({ id, location }): ReactElement => { 29 | const pageRef = useRef(null) 30 | const dispatch = useDispatch() 31 | const toolColor = useSelector((state: RootState) => state.tools.color) 32 | const pdfUrl = useSelector((state: RootState) => state.room.pdfUrl) 33 | const users = useSelector((state: RootState) => state.room.users) 34 | const userID = useSelector((state: RootState) => state.room.userID) 35 | const selectedTool = useSelector( 36 | (state: RootState) => state.tools.tools[state.tools.selectedIdx] 37 | ) 38 | 39 | useEffect(() => { 40 | dispatch(actions.setToolColor(randomColor({ luminosity: "bright" }))) 41 | }, []) 42 | 43 | const { showMouse, setShowMouse, ownMouseX, ownMouseY } = usePointer( 44 | id, 45 | pageRef 46 | ) 47 | 48 | const { 49 | error, 50 | socketChangePage, 51 | socketUpdatePresenter, 52 | socketUpdateZoom, 53 | socketUpdateScroll, 54 | } = useSocket(id) 55 | 56 | useEffect(() => { 57 | if (!selectedTool) { 58 | if (showMouse) { 59 | setShowMouse(false) 60 | } 61 | return 62 | } 63 | 64 | switch (selectedTool.name) { 65 | case "pointer": 66 | setShowMouse(true) 67 | break 68 | } 69 | }, [selectedTool]) 70 | 71 | const history = useHistory() 72 | const handleClose = (): void => { 73 | const leaveRoomData: RoomData = { roomID: id } 74 | socket.emit("leave room", leaveRoomData) 75 | dispatch(actions.clearRoom()) 76 | dispatch(actions.clearZoom()) 77 | dispatch(actions.clearPages()) 78 | dispatch(actions.clearTools()) 79 | history.push("/") 80 | } 81 | 82 | const renderPointers = (): ReactElement => { 83 | return ( 84 | <> 85 | {users.map(user => { 86 | if (user.id !== userID && user.mouseX && user.mouseY) { 87 | if (!pageRef.current) return 88 | 89 | const { 90 | offsetLeft, 91 | offsetTop, 92 | clientWidth, 93 | clientHeight, 94 | offsetParent, 95 | } = pageRef.current 96 | 97 | const mouseX = offsetLeft + user.mouseX * clientWidth 98 | const mouseY = 99 | offsetTop + offsetParent.offsetTop + user.mouseY * clientHeight 100 | 101 | return ( 102 | 108 | ) 109 | } 110 | })} 111 | 112 | ) 113 | } 114 | 115 | const renderOwnPointer = (): ReactElement => { 116 | return showMouse ? ( 117 | 118 | ) : null 119 | } 120 | 121 | const renderHostModal = (): ReactElement => { 122 | if (!location?.state) return null 123 | return (location.state as LocationState).host ? ( 124 | 125 | ) : null 126 | } 127 | 128 | const renderRoom = (): ReactElement => { 129 | return ( 130 | 131 | {renderHostModal()} 132 | {renderPointers()} 133 | {renderOwnPointer()} 134 | 135 | {pdfUrl ? ( 136 | 140 | ) : null} 141 | 142 | 147 | 148 | ) 149 | } 150 | 151 | return ( 152 |
{error && id !== "room-test" ? `ERROR: ${error}` : renderRoom()}
153 | ) 154 | } 155 | 156 | export default withRouter(Room) as any 157 | -------------------------------------------------------------------------------- /frontend/src/components/Room/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { Background } from "../globalStyles" 4 | 5 | const RoomBackground = styled(Background)` 6 | background-color: lightgray; 7 | ` 8 | 9 | export { RoomBackground } 10 | -------------------------------------------------------------------------------- /frontend/src/components/TestRoutes.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react" 2 | import { Route } from "react-router-dom" 3 | 4 | import BeamingModal from "./BeamingModal" 5 | import Loading from "./Loading" 6 | import LinkModal from "./LinkModal" 7 | import NavBar from "./NavBar" 8 | import ToolBar from "./ToolBar" 9 | import ZoomBar from "./ZoomBar" 10 | import Room from "./Room" 11 | 12 | const TestRoutes = (): ReactElement => { 13 | return ( 14 | <> 15 | 16 | 17 | ( 20 | {}} 24 | /> 25 | )} 26 | /> 27 | 28 | ( 31 | {}} 35 | /> 36 | )} 37 | /> 38 | 39 | } 42 | /> 43 | 44 | ( 47 | {}} 49 | socketChangePage={(pageNum: number): void => { 50 | pageNum 51 | }} 52 | /> 53 | )} 54 | /> 55 | 56 | ( 59 | {}} /> 60 | )} 61 | /> 62 | 63 | ( 66 | {}} 68 | socketUpdateZoom={(): void => {}} 69 | socketUpdateScroll={(): void => {}} 70 | /> 71 | )} 72 | /> 73 | 74 | } 77 | /> 78 | 79 | ) 80 | } 81 | 82 | export default TestRoutes 83 | -------------------------------------------------------------------------------- /frontend/src/components/ToolBar/Palette/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useEffect, 4 | FC, 5 | ReactElement, 6 | ChangeEvent, 7 | MouseEvent, 8 | } from "react" 9 | import { useSelector, useDispatch } from "react-redux" 10 | 11 | import { 12 | Container, 13 | InnerContainer, 14 | Triangle, 15 | Color, 16 | InputDiv, 17 | HashDiv, 18 | Hash, 19 | ColorInput, 20 | PaletteCover, 21 | } from "./styles" 22 | import { setToolColor } from "../../../store/actions" 23 | import { RootState } from "../../../store/types" 24 | 25 | type PropTypes = { 26 | show: boolean 27 | handleShow(): void 28 | } 29 | 30 | const createColors = (firstColor: string): string[] => [ 31 | firstColor, 32 | "#F2994A", 33 | "#F2C94C", 34 | "#219653", 35 | "#6FCF97", 36 | "#2F80ED", 37 | "#2D9CDB", 38 | ] 39 | 40 | const Palette: FC = ({ show, handleShow }): ReactElement => { 41 | const toolColor = useSelector((state: RootState) => state.tools.color) 42 | const dispatch = useDispatch() 43 | const [colors, setColors] = useState([]) 44 | const [presetColorUsed, setPresetColorUsed] = useState(true) 45 | useEffect(() => { 46 | if (!toolColor) return 47 | if (colors.length) { 48 | const usingPresetColor = !!colors.find(color => color === toolColor) 49 | setPresetColorUsed(usingPresetColor) 50 | } else { 51 | setColors(createColors(toolColor)) 52 | } 53 | }, [toolColor]) 54 | 55 | const handleInputChange = (ev: ChangeEvent): void => { 56 | // Basic validation: only accept up to 6 characters 57 | // valid hex values (a-f, 0-9) 58 | const hex = ev.target.value 59 | const hexRegex = /^[a-f0-9]+$/gi 60 | if (hex.length > 6 || (hex && !hexRegex.test(hex))) return 61 | 62 | setToolColor(`#${hex}`) 63 | } 64 | 65 | const handleColorClick = (ev: MouseEvent): void => { 66 | const target = ev.target as HTMLDivElement 67 | dispatch(setToolColor(target.id)) 68 | } 69 | 70 | const renderPalette = (): ReactElement => { 71 | return ( 72 | <> 73 | 74 | 75 | {colors.map((color, i) => ( 76 | 83 | ))} 84 | 85 | 86 | # 87 | 88 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | ) 100 | } 101 | 102 | return show ? renderPalette() : null 103 | } 104 | 105 | export default Palette 106 | -------------------------------------------------------------------------------- /frontend/src/components/ToolBar/Palette/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css, FlattenSimpleInterpolation } from "styled-components" 2 | 3 | import { Cover, P, Input, COLORS } from "../../globalStyles" 4 | 5 | export const Container = styled.div` 6 | width: calc(11rem + 1.25rem); 7 | height: 5.25rem; 8 | position: absolute; 9 | top: calc(-5.25rem / 2 + 2.25rem / 2); 10 | right: 1.8rem; 11 | transition: opacity ease 0.25s; 12 | z-index: 99; 13 | ` 14 | 15 | export const Triangle = styled.div` 16 | width: 0; 17 | height: 0; 18 | border-left: 1.25rem solid ${COLORS.LIGHT_GRAY}; 19 | border-top: 0.8125rem solid transparent; 20 | border-bottom: 0.8125rem solid transparent; 21 | position: absolute; 22 | top: calc(5.25rem / 2 - 0.8125rem); 23 | right: 0; 24 | ` 25 | 26 | export const InnerContainer = styled.div` 27 | box-sizing: border-box; 28 | width: 11.25rem; 29 | height: 100%; 30 | padding: 0.75rem; 31 | background-color: ${COLORS.LIGHT_GRAY}; 32 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25); 33 | display: flex; 34 | flex-wrap: wrap; 35 | justify-content: space-between; 36 | align-content: space-between; 37 | ` 38 | 39 | type ColorProps = { 40 | color: string 41 | selected: boolean 42 | } 43 | 44 | export const Color = styled.button` 45 | box-sizing: border-box; 46 | width: 1.5rem; 47 | height: 1.5rem; 48 | margin-right: 0.5rem; 49 | background-color: ${(props): string => props.color}; 50 | border: 0; 51 | border-radius: 1.25rem; 52 | flex: 1 1 1.5rem; 53 | 54 | &:nth-child(5) { 55 | margin-right: 0; 56 | } 57 | 58 | &:hover { 59 | box-shadow: 0 0 0.35rem ${(props): string => props.color}; 60 | } 61 | 62 | ${(props): FlattenSimpleInterpolation => 63 | props.selected && 64 | css` 65 | border: 2px solid ${COLORS.SPACE_GRAY}; 66 | `} 67 | ` 68 | 69 | export const InputDiv = styled.div` 70 | height: 1.5rem; 71 | display: flex; 72 | flex: 3 3 calc(3 * 1.5rem + 2 * 0.5rem); 73 | ` 74 | 75 | export const HashDiv = styled.div` 76 | width: 1rem; 77 | height: 100%; 78 | color: ${COLORS.DARK_GRAY}; 79 | background-color: ${COLORS.MID_GRAY}; 80 | display: flex; 81 | ` 82 | 83 | export const Hash = styled(P)` 84 | margin: auto; 85 | color: ${COLORS.DARK_GRAY}; 86 | font-weight: 700; 87 | user-select: none; 88 | -webkit-user-select: none; 89 | -moz-user-select: none; 90 | -ms-user-select: none; 91 | ` 92 | 93 | type ColorInputProps = { 94 | presetColorUsed: boolean 95 | } 96 | 97 | export const ColorInput = styled(Input)` 98 | width: calc(100% - 1rem); 99 | 100 | ${(props): FlattenSimpleInterpolation => 101 | !props.presetColorUsed && 102 | css` 103 | font-weight: 600; 104 | border: 2px solid ${COLORS.SPACE_GRAY}; 105 | `} 106 | ` 107 | 108 | export const PaletteCover = styled(Cover)` 109 | background-color: rgba(0, 0, 0, 0); 110 | ` 111 | -------------------------------------------------------------------------------- /frontend/src/components/ToolBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, FC, ReactElement } from "react" 2 | import { useSelector, useDispatch } from "react-redux" 3 | 4 | import Palette from "./Palette" 5 | import { 6 | ButtonsContainer, 7 | ButtonsInnerContainer, 8 | ToolBarButton, 9 | ColorIndicator, 10 | } from "./styles" 11 | import * as actions from "../../store/actions" 12 | import { RootState } from "../../store/types" 13 | 14 | type PropTypes = { 15 | socketUpdatePresenter: () => void 16 | socketUpdateZoom: (zoomLevel: number) => void 17 | socketUpdateScroll: (scrollLeft: number, scrollTop: number) => void 18 | } 19 | 20 | const ToolBar: FC = ({ 21 | socketUpdatePresenter, 22 | socketUpdateZoom, 23 | socketUpdateScroll, 24 | }): ReactElement => { 25 | const userID = useSelector((state: RootState) => state.room.userID) 26 | const presenterMode = useSelector( 27 | (state: RootState) => !!state.room.presenterID 28 | ) 29 | const isPresenter = useSelector( 30 | (state: RootState) => state.room.userID === state.room.presenterID 31 | ) 32 | const tools = useSelector((state: RootState) => state.tools.tools) 33 | const selectedToolIdx = useSelector( 34 | (state: RootState) => state.tools.selectedIdx 35 | ) 36 | const toolColor = useSelector((state: RootState) => state.tools.color) 37 | const zoomLevel = useSelector((state: RootState) => state.zoom.zoomLevel) 38 | const scrollLeftRatio = useSelector( 39 | (state: RootState) => state.zoom.scrollLeftRatio 40 | ) 41 | const scrollTopRatio = useSelector( 42 | (state: RootState) => state.zoom.scrollTopRatio 43 | ) 44 | 45 | const dispatch = useDispatch() 46 | const [showPalette, setShowPalette] = useState(false) 47 | const handlePalette = (): void => { 48 | setShowPalette(prev => !prev) 49 | } 50 | 51 | const handleToolClick = (clickedIdx: number): void => { 52 | // Last tool is presenter tool 53 | // setPresenter can work like a toggle on the socket server side 54 | if (clickedIdx === tools.length - 1) { 55 | dispatch(actions.setPresenter(userID)) 56 | socketUpdatePresenter() 57 | socketUpdateZoom(zoomLevel) 58 | socketUpdateScroll(scrollLeftRatio, scrollTopRatio) 59 | } else { 60 | const toolIdx = selectedToolIdx === clickedIdx ? null : clickedIdx 61 | dispatch(actions.selectTool(toolIdx)) 62 | } 63 | } 64 | 65 | return ( 66 | 67 | 68 | 69 | 70 | {tools.map((tool, i) => ( 71 | handleToolClick(i)} 79 | active={ 80 | selectedToolIdx === i || (i === tools.length - 1 && isPresenter) 81 | } 82 | disabled={i === tools.length - 1 && presenterMode && !isPresenter} 83 | /> 84 | ))} 85 | 86 | 87 | ) 88 | } 89 | 90 | export default ToolBar 91 | -------------------------------------------------------------------------------- /frontend/src/components/ToolBar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | import { 4 | VerticalButtonsContainer, 5 | ToolButton, 6 | COLORS, 7 | ANIMATION_DURATION, 8 | } from "../globalStyles" 9 | export const ButtonsContainer = styled(VerticalButtonsContainer)` 10 | right: 2rem; 11 | box-shadow: none; 12 | ` 13 | 14 | type InnerContainerProps = { 15 | count: number 16 | } 17 | 18 | export const ButtonsInnerContainer = styled.div` 19 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25); 20 | height: ${(props): string => `${props.count * 2.25}rem`}; 21 | display: flex; 22 | flex-direction: column; 23 | ` 24 | 25 | type ToolBarButtonProps = { 26 | active: boolean 27 | } 28 | 29 | export const ToolBarButton = styled(ToolButton)` 30 | ${(props): any => 31 | props.active && 32 | css` 33 | background-color: ${COLORS.SPACE_GRAY}; 34 | background-image: url(${props.imageHover}); 35 | 36 | &:hover:enabled { 37 | background-color: ${COLORS.SPACE_GRAY}; 38 | } 39 | `} 40 | ` 41 | 42 | type ColorIndicatorProps = { 43 | color: string 44 | } 45 | 46 | export const ColorIndicator = styled.button` 47 | box-sizing: border-box; 48 | width: 2.25rem; 49 | height: 2.25rem; 50 | border-radius: 2.25rem; 51 | background-color: ${(props): string => props.color}; 52 | margin-bottom: 0.875rem; 53 | border: 0; 54 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25); 55 | transition: box-shadow ease ${ANIMATION_DURATION}; 56 | 57 | &:hover, 58 | &:focus { 59 | box-shadow: 0 0 0.65rem rgba(0, 0, 0, 0.5); 60 | } 61 | ` 62 | -------------------------------------------------------------------------------- /frontend/src/components/ZoomBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, MouseEvent } from "react" 2 | import { useSelector, useDispatch } from "react-redux" 3 | 4 | import { ToolButton } from "../globalStyles" 5 | import { ButtonsContainer } from "./styles" 6 | 7 | import { zoomTools } from "../../utils/tools" 8 | import { setZoomLevel } from "../../store/actions" 9 | import { RootState } from "../../store/types" 10 | 11 | enum ZOOM_LIMIT { 12 | MIN = 1, 13 | MAX = 5, 14 | } 15 | 16 | type PropTypes = { 17 | socketUpdateZoom(zoom: number): void 18 | } 19 | 20 | const ZoomBar: FC = ({ socketUpdateZoom }): ReactElement => { 21 | const presenterMode = useSelector( 22 | (state: RootState) => !!state.room.presenterID 23 | ) 24 | const isPresenter = useSelector( 25 | (state: RootState) => state.room.userID === state.room.presenterID 26 | ) 27 | const zoomLevel = useSelector((state: RootState) => state.zoom.zoomLevel) 28 | const dispatch = useDispatch() 29 | 30 | const handleClick = (ev: MouseEvent): void => { 31 | const target = ev.target as HTMLButtonElement 32 | const broadcast = presenterMode && isPresenter 33 | let newZoomLevel: number 34 | 35 | if (target.id === "zoomIn" && zoomLevel < ZOOM_LIMIT.MAX) { 36 | newZoomLevel = zoomLevel + 1 37 | } else if (target.id === "zoomOut" && zoomLevel > ZOOM_LIMIT.MIN) { 38 | newZoomLevel = zoomLevel - 1 39 | } else if (target.id === "zoomReset") { 40 | newZoomLevel = 1 41 | } 42 | 43 | dispatch(setZoomLevel(newZoomLevel)) 44 | if (broadcast) { 45 | socketUpdateZoom(newZoomLevel) 46 | } 47 | } 48 | 49 | return ( 50 | 51 | {zoomTools.map( 52 | (tool, i): ReactElement => ( 53 | 64 | ) 65 | )} 66 | 67 | ) 68 | } 69 | 70 | export default ZoomBar 71 | -------------------------------------------------------------------------------- /frontend/src/components/ZoomBar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import { VerticalButtonsContainer } from "../globalStyles" 4 | 5 | export const ButtonsContainer = styled(VerticalButtonsContainer)` 6 | left: 2rem; 7 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25); 8 | ` 9 | -------------------------------------------------------------------------------- /frontend/src/components/globalStyles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export enum COLORS { 4 | GREEN = "#308613", 5 | MID_GREEN = "#226112", 6 | DARK_GREEN = "#164709", 7 | MUSTARD = "#D6AC2D", 8 | MID_MUSTARD = "#97781A", 9 | DARK_MUSTARD = "#674F05", 10 | RED = "#C73B28", 11 | MID_RED = "#972819", 12 | DARK_RED = "#7E2613", 13 | LIGHT_GRAY = "#F2F2F2", 14 | MID_GRAY = "#C7C7C7", 15 | DARK_GRAY = "#6E6E6E", 16 | SPACE_GRAY = "#14161B", 17 | DOCUMENT_VIEW_BG = "#CBCBCB", 18 | WHITE = "#ffffff", 19 | BLACK = "#000000", 20 | } 21 | 22 | export const ANIMATION_DURATION = "0.2s" 23 | 24 | type BackgroundProps = { 25 | color: string 26 | } 27 | 28 | export const Background = styled.div` 29 | width: 100%; 30 | height: 100%; 31 | min-height: 100vh; 32 | margin: 0 auto; 33 | background-color: ${(props): string => props.color}; 34 | display: flex; 35 | ` 36 | 37 | export const Cover = styled.div` 38 | width: 100vw; 39 | height: 100vh; 40 | position: fixed; 41 | top: 0; 42 | left: 0; 43 | z-index: 90; 44 | background-color: rgba(0, 0, 0, 0.3); 45 | display: flex; 46 | ` 47 | 48 | export const ModalCover = styled(Cover)` 49 | z-index: 9998; 50 | ` 51 | 52 | export const Island = styled.div` 53 | width: 20%; 54 | min-width: 250px; 55 | max-width: 325px; 56 | margin: auto; 57 | padding: 1.5rem 2.5rem 2rem 2.5rem; 58 | background-color: white; 59 | box-shadow: 0 0 1.5rem rgba(0, 0, 0, 0.25); 60 | ` 61 | 62 | export const VerticalButtonsContainer = styled.div` 63 | width: 2.25rem; 64 | display: flex; 65 | flex-direction: column; 66 | position: fixed; 67 | bottom: 2rem; 68 | z-index: 1000; 69 | ` 70 | 71 | export const Button = styled.button` 72 | font-family: "IBM Plex Sans", sans-serif; 73 | font-weight: 600; 74 | font-size: 1.125rem; 75 | border: 0; 76 | border-radius: 2.5rem; 77 | margin: 0 auto; 78 | color: ${COLORS.WHITE}; 79 | background-color: ${COLORS.SPACE_GRAY}; 80 | cursor: pointer; 81 | height: 2.5rem; 82 | transition: all ${ANIMATION_DURATION} ease-in-out; 83 | 84 | &:hover { 85 | box-shadow: 0 0 0.5rem ${COLORS.SPACE_GRAY}; 86 | } 87 | 88 | &:active { 89 | color: ${COLORS.MID_GRAY}; 90 | } 91 | 92 | &:disabled { 93 | border: 0; 94 | color: ${COLORS.LIGHT_GRAY}; 95 | background-color: ${COLORS.MID_GRAY}; 96 | cursor: not-allowed; 97 | } 98 | ` 99 | 100 | export const ButtonRound = styled(Button)` 101 | width: 4rem; 102 | height: 4rem; 103 | border-radius: 2rem; 104 | ` 105 | 106 | type ToolButtonProps = { 107 | width: string 108 | height: string 109 | image: string 110 | imageHover?: string 111 | imageActive?: string 112 | } 113 | 114 | export const ToolButton = styled.button` 115 | width: ${(props): string => props.width}; 116 | height: ${(props): string => props.height}; 117 | border: 0; 118 | padding: 0; 119 | background-color: ${COLORS.LIGHT_GRAY}; 120 | background-image: url(${(props): string => props.image}); 121 | background-repeat: no-repeat; 122 | background-size: 100% 100%; 123 | background-position: center; 124 | transition: all ${ANIMATION_DURATION} ease-in-out; 125 | 126 | &:hover:enabled { 127 | background-color: ${COLORS.DARK_GRAY}; 128 | } 129 | 130 | &:active:enabled { 131 | background-color: ${COLORS.SPACE_GRAY}; 132 | } 133 | 134 | &:disabled { 135 | cursor: not-allowed; 136 | opacity: 0.5; 137 | } 138 | 139 | ${(props): any => 140 | props.imageHover && 141 | css` 142 | &:hover:enabled { 143 | background-image: url(${props.imageHover}); 144 | } 145 | `} 146 | 147 | ${(props): any => 148 | props.imageActive && 149 | css` 150 | &:active:enabled { 151 | background-image: url(${props.imageActive}); 152 | } 153 | `} 154 | ` 155 | 156 | export const H1 = styled.h1` 157 | font-family: "IBM Plex Sans", sans-serif; 158 | font-weight: 300; 159 | font-size: 3rem; 160 | ` 161 | 162 | export const H2 = styled.h2` 163 | font-family: "IBM Plex Sans", sans-serif; 164 | font-size: 1.875rem; 165 | font-weight: 600; 166 | ` 167 | 168 | export const H3 = styled.h3` 169 | font-family: "IBM Plex Sans", sans-serif; 170 | font-size: 1.1875rem; 171 | font-weight: 400; 172 | ` 173 | 174 | export const H4 = styled.h4` 175 | font-family: "IBM Plex Sans", sans-serif; 176 | font-size: 0.9375rem; 177 | font-weight: 600; 178 | ` 179 | 180 | export const P = styled.p` 181 | font-family: "IBM Plex Sans", sans-serif; 182 | font-size: 1rem; 183 | font-weight: 400; 184 | ` 185 | 186 | export const Code = styled.p` 187 | font-family: "IBM Plex Mono", monospace; 188 | font-size: 0.8125rem; 189 | font-weight: 400; 190 | text-transform: uppercase; 191 | ` 192 | 193 | export const Bold = styled.strong` 194 | font-family: "IBM Plex Sans", sans-serif; 195 | font-weight: 600; 196 | ` 197 | 198 | export const Input = styled.input` 199 | box-sizing: border-box; 200 | font-family: "IBM Plex Sans", sans-serif; 201 | font-size: 1rem; 202 | background-color: ${COLORS.WHITE}; 203 | border: 1px solid ${COLORS.MID_GRAY}; 204 | padding: 0 0.25rem; 205 | 206 | &:focus { 207 | border: 2px solid ${COLORS.SPACE_GRAY}; 208 | } 209 | ` 210 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "react-hot-loader/patch" 2 | 3 | import React from "react" 4 | import ReactDOM from "react-dom" 5 | import { Provider } from "react-redux" 6 | 7 | import App from "./components/App" 8 | import store from "./store" 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById("root") 15 | ) 16 | -------------------------------------------------------------------------------- /frontend/src/socket.ts: -------------------------------------------------------------------------------- 1 | import io from "socket.io-client" 2 | 3 | let socket: SocketIOClient.Socket 4 | if (process.env.NODE_ENV === "production") { 5 | socket = io() 6 | } else { 7 | // Default back end port 8 | socket = io(`http://localhost:3030`) 9 | } 10 | 11 | export default socket 12 | -------------------------------------------------------------------------------- /frontend/src/store/actions.ts: -------------------------------------------------------------------------------- 1 | import * as constants from "./constants" 2 | import { RoomAction, ZoomAction, PagesAction, ToolAction } from "./types" 3 | import { User } from "../../../backend/src/sockets/types" 4 | 5 | ///////////////////// 6 | // Room // 7 | ///////////////////// 8 | 9 | export const setUsers = (users: User[]): RoomAction => ({ 10 | type: constants.SET_USERS, 11 | users, 12 | }) 13 | 14 | export const setUserID = (id: string): RoomAction => ({ 15 | type: constants.SET_USER_ID, 16 | id, 17 | }) 18 | 19 | export const setPdfUrl = (url: string): RoomAction => ({ 20 | type: constants.SET_PDF_URL, 21 | url, 22 | }) 23 | 24 | export const clearRoom = (): RoomAction => ({ 25 | type: constants.CLEAR_ROOM, 26 | }) 27 | 28 | export const setFilename = (filename: string): RoomAction => ({ 29 | type: constants.SET_FILENAME, 30 | filename, 31 | }) 32 | 33 | export const setPresenter = (presenterID: string): RoomAction => ({ 34 | type: constants.SET_PRESENTER, 35 | presenterID, 36 | }) 37 | 38 | ///////////////////// 39 | // Zooms // 40 | ///////////////////// 41 | 42 | export const setZoomLevel = (zoomLevel: number): ZoomAction => ({ 43 | type: constants.SET_ZOOM_LEVEL, 44 | zoomLevel, 45 | }) 46 | 47 | export const setScrollRatios = (left: number, top: number): ZoomAction => ({ 48 | type: constants.SET_SCROLL_RATIOS, 49 | left, 50 | top, 51 | }) 52 | 53 | export const clearZoom = (): ZoomAction => ({ 54 | type: constants.CLEAR_ZOOM, 55 | }) 56 | 57 | ///////////////////// 58 | // Pages // 59 | ///////////////////// 60 | 61 | export const setPages = (pages: string[]): PagesAction => ({ 62 | type: constants.SET_PAGES, 63 | pages, 64 | }) 65 | 66 | export const goToPage = (pageNum: number): PagesAction => ({ 67 | type: constants.GO_TO_PAGE, 68 | pageNum, 69 | }) 70 | 71 | export const clearPages = (): PagesAction => ({ 72 | type: constants.CLEAR_PAGES, 73 | }) 74 | 75 | export const cachePage = (page: string): PagesAction => ({ 76 | type: constants.CACHE_PAGE, 77 | page, 78 | }) 79 | 80 | ///////////////////// 81 | // Tools // 82 | ///////////////////// 83 | 84 | export const selectTool = (idx: number): ToolAction => ({ 85 | type: constants.SELECT_TOOL, 86 | idx, 87 | }) 88 | 89 | export const setToolColor = (hex: string): ToolAction => ({ 90 | type: constants.SET_TOOL_COLOR, 91 | color: hex, 92 | }) 93 | 94 | export const clearTools = (): ToolAction => ({ 95 | type: constants.CLEAR_TOOLS, 96 | }) 97 | -------------------------------------------------------------------------------- /frontend/src/store/constants.ts: -------------------------------------------------------------------------------- 1 | // Room 2 | export const SET_USERS = "SET_USERS" 3 | export const SET_PDF_URL = "SET_PDF_URL" 4 | export const CLEAR_ROOM = "CLEAR_ROOM" 5 | export const SET_FILENAME = "SET_FILENAME" 6 | export const SET_PRESENTER = "SET_PRESENTER" 7 | export const SET_USER_ID = "SET_USER_ID" 8 | 9 | // Zoom & Pan 10 | export const SET_ZOOM_LEVEL = "SET_ZOOM_LEVEL" 11 | export const SET_SCROLL_RATIOS = "SET_SCROLL_RATIOS" 12 | export const CLEAR_ZOOM = "CLEAR_ZOOM" 13 | 14 | // Page navigation 15 | export const GO_TO_PAGE = "GO_TO_PAGE" 16 | export const SET_PAGES = "SET_PAGES" 17 | export const CLEAR_PAGES = "CLEAR_PAGES" 18 | export const CACHE_PAGE = "CACHE_PAGE" 19 | 20 | // Tools 21 | export const SELECT_TOOL = "SELECT_TOOL" 22 | export const SET_TOOL_COLOR = "SET_TOOL_COLOR" 23 | export const CLEAR_TOOLS = "CLEAR_TOOLS" 24 | -------------------------------------------------------------------------------- /frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux" 2 | import combineReducer from "./reducers" 3 | 4 | export default createStore(combineReducer) 5 | -------------------------------------------------------------------------------- /frontend/src/store/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux" 2 | 3 | import * as constants from "./constants" 4 | import * as types from "./types" 5 | import { pointerTools } from "../utils/tools" 6 | 7 | const initialRoomState: types.RoomState = { 8 | users: [], 9 | userID: "", 10 | pdfUrl: "", 11 | filename: "", 12 | presenterID: "", 13 | } 14 | const roomReducer = ( 15 | state = initialRoomState, 16 | action: types.RoomAction 17 | ): types.RoomState => { 18 | switch (action.type) { 19 | case constants.SET_USERS: 20 | return { ...state, users: action.users } 21 | case constants.SET_USER_ID: 22 | return { ...state, userID: action.id } 23 | case constants.SET_PDF_URL: 24 | return { ...state, pdfUrl: action.url } 25 | case constants.CLEAR_ROOM: 26 | return initialRoomState 27 | case constants.SET_FILENAME: 28 | return { ...state, filename: action.filename } 29 | case constants.SET_PRESENTER: 30 | return { ...state, presenterID: action.presenterID } 31 | default: 32 | return state 33 | } 34 | } 35 | 36 | const initialZoomState: types.ZoomState = { 37 | zoomLevel: 1, 38 | scrollTopRatio: 0.5, 39 | scrollLeftRatio: 0.5, 40 | } 41 | 42 | const zoomReducer = ( 43 | state = initialZoomState, 44 | action: types.ZoomAction 45 | ): types.ZoomState => { 46 | switch (action.type) { 47 | case constants.SET_ZOOM_LEVEL: 48 | return { ...state, zoomLevel: action.zoomLevel } 49 | case constants.SET_SCROLL_RATIOS: 50 | return { 51 | ...state, 52 | scrollLeftRatio: action.left, 53 | scrollTopRatio: action.top, 54 | } 55 | case constants.CLEAR_ZOOM: 56 | return initialZoomState 57 | default: 58 | return state 59 | } 60 | } 61 | 62 | const initialPageState: types.PageState = { 63 | currentPage: 1, 64 | pages: [], 65 | cached: {}, 66 | } 67 | 68 | const pagesReducer = ( 69 | state = initialPageState, 70 | action: types.PagesAction 71 | ): types.PageState => { 72 | switch (action.type) { 73 | case constants.SET_PAGES: 74 | return { ...state, pages: action.pages } 75 | case constants.GO_TO_PAGE: 76 | return { ...state, currentPage: action.pageNum } 77 | case constants.CLEAR_PAGES: 78 | return initialPageState 79 | case constants.CACHE_PAGE: 80 | return { ...state, cached: { ...state.cached, [action.page]: true } } 81 | default: 82 | return state 83 | } 84 | } 85 | 86 | const initialToolState: types.ToolState = { 87 | tools: pointerTools, 88 | selectedIdx: null, 89 | color: "", 90 | } 91 | 92 | const toolReducer = ( 93 | state = initialToolState, 94 | action: types.ToolAction 95 | ): types.ToolState => { 96 | switch (action.type) { 97 | case constants.SELECT_TOOL: 98 | return { ...state, selectedIdx: action.idx } 99 | case constants.SET_TOOL_COLOR: 100 | return { ...state, color: action.color } 101 | case constants.CLEAR_TOOLS: 102 | return initialToolState 103 | default: 104 | return state 105 | } 106 | } 107 | 108 | export default combineReducers({ 109 | room: roomReducer, 110 | zoom: zoomReducer, 111 | pages: pagesReducer, 112 | tools: toolReducer, 113 | }) 114 | -------------------------------------------------------------------------------- /frontend/src/store/types.ts: -------------------------------------------------------------------------------- 1 | import * as constants from "./constants" 2 | import { Tool } from "../utils/tools" 3 | import { User } from "../../../backend/src/sockets/types" 4 | 5 | ////////////////// 6 | // ACTION TYPES // 7 | ////////////////// 8 | 9 | type SetUsersAction = { 10 | type: typeof constants.SET_USERS 11 | users: User[] 12 | } 13 | 14 | type SetUserIdAction = { 15 | type: typeof constants.SET_USER_ID 16 | id: string 17 | } 18 | 19 | type SetPdfUrlAction = { 20 | type: typeof constants.SET_PDF_URL 21 | url: string 22 | } 23 | 24 | type ClearRoomAction = { 25 | type: typeof constants.CLEAR_ROOM 26 | } 27 | 28 | type SetFilenameAction = { 29 | type: typeof constants.SET_FILENAME 30 | filename: string 31 | } 32 | 33 | type SetPresenterAction = { 34 | type: typeof constants.SET_PRESENTER 35 | presenterID: string 36 | } 37 | 38 | export type RoomAction = 39 | | SetUsersAction 40 | | SetUserIdAction 41 | | SetPdfUrlAction 42 | | ClearRoomAction 43 | | SetFilenameAction 44 | | SetPresenterAction 45 | 46 | type SetZoomAction = { 47 | type: typeof constants.SET_ZOOM_LEVEL 48 | zoomLevel: number 49 | } 50 | 51 | type SetScrollAction = { 52 | type: typeof constants.SET_SCROLL_RATIOS 53 | left: number 54 | top: number 55 | } 56 | 57 | type ClearZoomAction = { 58 | type: typeof constants.CLEAR_ZOOM 59 | } 60 | 61 | export type ZoomAction = SetZoomAction | SetScrollAction | ClearZoomAction 62 | 63 | type GoToPageAction = { 64 | type: typeof constants.GO_TO_PAGE 65 | pageNum: number 66 | } 67 | 68 | type SetPagesAction = { 69 | type: typeof constants.SET_PAGES 70 | pages: string[] 71 | } 72 | 73 | type ClearPagesAction = { 74 | type: typeof constants.CLEAR_PAGES 75 | } 76 | 77 | type CachePageAction = { 78 | type: typeof constants.CACHE_PAGE 79 | page: string 80 | } 81 | 82 | export type PagesAction = 83 | | GoToPageAction 84 | | SetPagesAction 85 | | ClearPagesAction 86 | | CachePageAction 87 | 88 | type SelectToolAction = { 89 | type: typeof constants.SELECT_TOOL 90 | idx: number 91 | } 92 | 93 | type SetColorAction = { 94 | type: typeof constants.SET_TOOL_COLOR 95 | color: string 96 | } 97 | 98 | type ClearToolsAction = { 99 | type: typeof constants.CLEAR_TOOLS 100 | } 101 | 102 | export type ToolAction = SelectToolAction | SetColorAction | ClearToolsAction 103 | 104 | ///////////////// 105 | // STATE TYPES // 106 | ///////////////// 107 | 108 | export type RoomState = { 109 | users: User[] 110 | userID: string 111 | pdfUrl: string 112 | filename: string 113 | presenterID: string 114 | } 115 | 116 | export type ZoomState = { 117 | zoomLevel: number 118 | scrollLeftRatio: number 119 | scrollTopRatio: number 120 | } 121 | 122 | export type PageCache = { 123 | [pageFile: string]: boolean 124 | } 125 | 126 | export type PageState = { 127 | currentPage: number 128 | pages: string[] 129 | cached: PageCache 130 | } 131 | 132 | export type ToolState = { 133 | tools: Tool[] 134 | selectedIdx: number 135 | color: string 136 | } 137 | 138 | export type RootState = { 139 | room: RoomState 140 | zoom: ZoomState 141 | pages: PageState 142 | tools: ToolState 143 | } 144 | -------------------------------------------------------------------------------- /frontend/src/utils/apis.ts: -------------------------------------------------------------------------------- 1 | export const conveyorAPI = 2 | process.env.NODE_ENV === "production" 3 | ? "https://raa-conveyor.herokuapp.com/api" 4 | : "http://localhost:4000/api" 5 | 6 | export const pingbackAddress = 7 | process.env.NODE_ENV === "production" 8 | ? "https://raa-scotty.herokuapp.com/api/pingback" 9 | : "http://localhost:3030/api/pingback" 10 | -------------------------------------------------------------------------------- /frontend/src/utils/paddingUtils.ts: -------------------------------------------------------------------------------- 1 | export type Padding = { 2 | top: string 3 | right: string 4 | bottom: string 5 | left: string 6 | } 7 | 8 | export const sumPadding = (...paddings: Array): number => { 9 | return paddings.reduce((acc, _padding) => { 10 | const p = parseFloat(_padding.replace("px", "")) 11 | return acc + p 12 | }, 0) 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/utils/roundTo.ts: -------------------------------------------------------------------------------- 1 | export default (decimalPlaces: number): ((num: number) => number) => { 2 | if (decimalPlaces < 0) throw new Error("decimalPlaces must be >= 0") 3 | return (num: number): number => { 4 | const multiplier = 10 ** decimalPlaces 5 | return Math.round(num * multiplier) / multiplier 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/utils/tools.ts: -------------------------------------------------------------------------------- 1 | export type Tool = { 2 | name: string 3 | image: string 4 | hover?: string 5 | active?: string 6 | } 7 | 8 | const createTool = (name: string): Tool => ({ 9 | name, 10 | image: `/static/icons/${name}.svg`, 11 | hover: `/static/icons/${name}Light.svg`, 12 | }) 13 | 14 | export const pointerTools: Tool[] = ["pointer", "presenter"].map(tool => 15 | createTool(tool) 16 | ) 17 | 18 | export const zoomTools: Tool[] = ["zoomIn", "zoomOut", "zoomReset"].map(tool => 19 | createTool(tool) 20 | ) 21 | -------------------------------------------------------------------------------- /frontend/static/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/icons/closeLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/icons/comment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/static/icons/commentLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/static/icons/copyPasteboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/static/icons/copyPasteboardLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/static/icons/draw.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/drawLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/erase.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/eraseLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/firstLastPage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/icons/firstLastPageLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/static/icons/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/folderDark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/pointer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/pointerLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/presenter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/static/icons/presenterLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/static/icons/prevNextPage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/prevNextPageLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/zoomIn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/zoomInLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/zoomOut.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/zoomOutLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/static/icons/zoomReset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/static/icons/zoomResetLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/static/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /frontend/static/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/static/spinnerLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/static/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtanadi/scotty/290b034517e457b111674576f64ee64837267b5c/frontend/static/style.css -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["./backend/src/"], 3 | "ext": "js,ts", 4 | "ignore": ["./frontend/"], 5 | "exec": "ts-node ./backend/src/index.ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scotty", 3 | "version": "1.5.6", 4 | "description": "WebSocket-enabled PDF viewer", 5 | "main": "backend/index.js", 6 | "engines": { 7 | "node": "^12.16.1" 8 | }, 9 | "scripts": { 10 | "compile": "tsc", 11 | "build": "npm run compile && webpack --config webpack.prod.js", 12 | "dev": "npm run dev:front & npm run dev:server", 13 | "dev:front": "webpack-dev-server --config webpack.dev.js", 14 | "dev:server": "NODE_ENV=development nodemon", 15 | "start": "node backend/dist/index.js", 16 | "test": "jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/raa-tools/scotty.git" 21 | }, 22 | "keywords": [ 23 | "WebSocket", 24 | "PDF" 25 | ], 26 | "author": "JT", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/raa-tools/scotty/issues" 30 | }, 31 | "homepage": "https://github.com/raa-tools/scotty#readme", 32 | "dependencies": { 33 | "aws-sdk": "^2.814.0", 34 | "axios": "^0.21.2", 35 | "body-parser": "^1.19.0", 36 | "compression": "^1.7.4", 37 | "dotenv": "^8.2.0", 38 | "express": "^4.17.1", 39 | "randomcolor": "^0.5.4", 40 | "react": "^16.13.1", 41 | "react-dom": "^16.13.1", 42 | "react-hot-loader": "^4.12.20", 43 | "react-redux": "^7.2.0", 44 | "react-router-dom": "^5.1.2", 45 | "redux": "^4.0.5", 46 | "socket.io": "^2.4.0", 47 | "socket.io-client": "^2.3.0", 48 | "styled-components": "^5.0.1", 49 | "uuid": "^7.0.2" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.9.0", 53 | "@babel/preset-react": "^7.9.1", 54 | "@hot-loader/react-dom": "^16.13.0", 55 | "@types/compression": "^1.7.0", 56 | "@types/express": "^4.17.3", 57 | "@types/node": "^13.9.2", 58 | "@types/react": "^16.9.25", 59 | "@types/react-dom": "^16.9.5", 60 | "@types/react-redux": "^7.1.9", 61 | "@types/react-router-dom": "^5.1.3", 62 | "@types/redux": "^3.6.0", 63 | "@types/socket.io": "^2.1.4", 64 | "@types/socket.io-client": "^1.4.32", 65 | "@types/styled-components": "^5.0.1", 66 | "@types/uuid": "^7.0.2", 67 | "@types/webpack": "^4.41.8", 68 | "@types/webpack-dev-middleware": "^3.7.0", 69 | "@types/webpack-env": "^1.15.1", 70 | "@types/webpack-hot-middleware": "^2.25.0", 71 | "@typescript-eslint/eslint-plugin": "^2.24.0", 72 | "@typescript-eslint/parser": "^2.24.0", 73 | "babel": "^6.23.0", 74 | "babel-loader": "^8.1.0", 75 | "compression-webpack-plugin": "^4.0.0", 76 | "eslint": "^6.8.0", 77 | "eslint-config-prettier": "^6.10.0", 78 | "eslint-plugin-prettier": "^3.1.2", 79 | "eslint-plugin-react": "^7.19.0", 80 | "husky": "^4.2.3", 81 | "jest": "^25.1.0", 82 | "lint-staged": "^10.1.2", 83 | "nodemon": "^2.0.2", 84 | "prettier": "^1.19.1", 85 | "terser-webpack-plugin": "^2.3.5", 86 | "ts-loader": "^6.2.1", 87 | "ts-node": "^8.8.1", 88 | "typescript": "^3.8.3", 89 | "webpack": "^4.42.0", 90 | "webpack-cli": "^3.3.11", 91 | "webpack-dev-middleware": "^3.7.2", 92 | "webpack-dev-server": "^3.11.0", 93 | "webpack-hot-middleware": "^2.25.0", 94 | "webpack-merge": "^4.2.2" 95 | }, 96 | "husky": { 97 | "hooks": { 98 | "pre-commit": "lint-staged" 99 | } 100 | }, 101 | "lint-staged": { 102 | "*.js": "eslint --cache --fix", 103 | "*.ts": "eslint --cache --fix", 104 | "*.tsx": "eslint --cache --fix" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |

THIS PROJECT HAS BEEN ARCHIVED AS OF 2022-06-06

3 |

The app is still available on the Heroku link below. As of the time of this writing, it is still functional; however, there will not be any work done on the project in the near future.

4 |
5 | 6 |
7 | 8 |

🛸️ scotty 🛸️

9 |
10 | 11 | `scotty` is a WebSocket-enabled PDF viewer, allowing multiple clients to look at and browse through a document together in real time. ([*Who's Scotty?*](https://en.wikipedia.org/wiki/Beam_me_up,_Scotty)) 12 | 13 | 14 | 18 | 19 | ## Basic Functionality 20 | `scotty` is designed to be a lightweight app and isn't comparable to a product like Google Slides. The app's main purpose is to allow multiple people to have the same view of the same document in real time, as if they're in the same room together (page navigations are synchronized, so everyone is always on the same page). 21 | 22 | #### Hosting 23 | 1. Upload PDF 24 | 2. Once the PDF has been uploaded, the client will be redirected to a private `room`. 25 | 3. Share the `room`'s URL with everyone on your team. 26 | 27 | #### Joining 28 | 1. Navigate to link provided by host 29 | 30 | ## Additional Functionality 31 | `scotty` is an early-stage work-in-progress. This means that while clients can upload and view PDFs, the app's UI/UX is fairly limited. A redesign is currently in the works and can be viewed [here](https://www.figma.com/file/nB8XWWZCOI7kFJGivVbsWh/scotty?node-id=0%3A1). 32 | 33 | A list of additional functionalities (and bugs) can be viewed on the project's [issues page](https://github.com/raa-tools/scotty/issues). 34 | 35 | ## Technical Information 36 | Because internal documents contain confidential information, they are treated with caution. That said, there is still room for improvement, such as adding authentication, making S3 permissions more restrictive, etc. 37 | 38 | ### Currently... 39 | - Uses HTTPS by default (through Heroku) 40 | - PDFs are hosted on Amazon's S3 only for the duration of a session. 41 | - Once all clients leave the room associated with a PDF, the file object is deleted from S3. 42 | - S3 bucket's CORS only allowed deployed URL as origin 43 | - `room` IDs, `user` IDs, and temporary file names are all stored in memory. 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "alwaysStrict": true, 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "jsx": "react", 8 | "lib": ["dom", "es2015"], 9 | "moduleResolution": "node", 10 | "outDir": "./backend/dist", 11 | "sourceMap": true, 12 | "target": "ES6", 13 | "typeRoots": ["node_modules/@types"], 14 | "types": [] 15 | }, 16 | "exclude": ["node_modules"], 17 | "include": ["backend/src/**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | entry: ["./frontend/src/index.tsx"], 5 | output: { 6 | filename: "index.js", 7 | path: path.join(__dirname, "/frontend/dist"), 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx*$/, 13 | exclude: /node_modules/, 14 | use: ["ts-loader"], 15 | }, 16 | { 17 | test: /\.jsx*$/, 18 | exclude: /node_modules/, 19 | use: ["babel-loader"], 20 | }, 21 | ], 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { HotModuleReplacementPlugin } = require("webpack") 2 | const merge = require("webpack-merge") 3 | const path = require("path") 4 | 5 | const common = require("./webpack.common") 6 | 7 | // Important that this port is 3000 8 | // because of our restrictive S3 bucket 9 | const PORT = 3000 10 | const PROXY = process.env.PROXY || "http://localhost:3030" 11 | 12 | module.exports = merge(common, { 13 | mode: "development", 14 | devServer: { 15 | contentBase: path.join(__dirname, "/frontend/"), 16 | compress: true, 17 | hot: true, 18 | open: true, 19 | port: PORT, 20 | proxy: { 21 | "/": PROXY, 22 | }, 23 | }, 24 | plugins: [new HotModuleReplacementPlugin()], 25 | resolve: { 26 | extensions: ["*", ".js", ".jsx", ".ts", ".tsx"], 27 | alias: { 28 | "react-dom": "@hot-loader/react-dom", 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge") 2 | const TerserPlugin = require("terser-webpack-plugin") 3 | const CompressionPlugin = require("compression-webpack-plugin") 4 | 5 | const common = require("./webpack.common") 6 | 7 | module.exports = merge(common, { 8 | mode: "production", 9 | optimization: { 10 | minimize: true, 11 | minimizer: [new TerserPlugin()], 12 | }, 13 | plugins: [ 14 | new CompressionPlugin({ 15 | test: /\.[jt]sx*$/, 16 | }), 17 | ], 18 | resolve: { 19 | extensions: ["*", ".js", ".jsx", ".ts", ".tsx"], 20 | }, 21 | }) 22 | --------------------------------------------------------------------------------