├── .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 |
20 |
21 |
22 |
23 |
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 |
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 |
--------------------------------------------------------------------------------