This commit is contained in:
Dane Johnson 2023-01-31 16:36:37 -06:00
parent 4ad1f7c217
commit cef5fc9879
25 changed files with 8257 additions and 0 deletions

6
.dir-locals.el Normal file
View File

@ -0,0 +1,6 @@
;;; Directory Local Variables
;;; For more information see (info "(emacs) Directory Variables")
((js-mode . ((js-indent-level . 2)))
(web-mode . ((mode . prettier)
(indent-tabs-mode . nil))))

17
.eslintrc.yml Normal file
View File

@ -0,0 +1,17 @@
env:
browser: true
es2021: true
extends:
- eslint:recommended
- plugin:react/recommended
- plugin:react/jsx-runtime
- plugin:@typescript-eslint/recommended
overrides: []
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: latest
sourceType: module
plugins:
- react
- '@typescript-eslint'
rules: {}

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Venture</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
crossorigin="anonymous"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7640
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "venture-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite --host",
"build": "tsc && vite build",
"preview": "vite preview",
"pretty": "prettier -w src"
},
"dependencies": {
"@reduxjs/toolkit": "^1.9.2",
"bootstrap": "^5.2.3",
"konva": "^8.4.2",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-bootstrap": "^2.7.0",
"react-dom": "^18.2.0",
"react-konva": "^18.2.3",
"react-redux": "^8.0.5",
"react-router": "^6.7.0",
"react-router-dom": "^6.7.0",
"socket.io-client": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.33.0",
"eslint-plugin-react": "^7.32.2",
"prettier": "^2.8.3",
"typescript": "^4.9.5",
"vite": "^4.0.0",
"vite-plugin-eslint": "^1.8.1"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

25
src/App.tsx Normal file
View File

@ -0,0 +1,25 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Provider } from "react-redux";
import store from "./store";
import Error from "./Error";
import Landing from "./Landing";
import Display from "./Display";
import Contestant from "./Contestant";
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="new-game" element={<Display />} />
<Route path="contestant-join/:room" element={<Contestant />} />
<Route path="/*" element={<Error />} />
</Routes>
</BrowserRouter>
</Provider>
);
};
export default App;

53
src/Contestant.tsx Normal file
View File

@ -0,0 +1,53 @@
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { debounce } from "lodash";
import { socket } from "./socket";
import { useAppSelector } from "./hooks";
import { selectCanBuzz, selectSignature } from "./store/contestantSlice";
const Contestant = () => {
const { room } = useParams();
const signature = useAppSelector(selectSignature);
const canBuzz = useAppSelector(selectCanBuzz);
useEffect(() => {
socket.emit("contestant-join", { room, signature });
}, []);
useEffect(() => {
if (parent.current) {
setWidth(parent.current.getBoundingClientRect().width);
}
}, [parent]);
const handleBuzz = debounce(
() => {
if (canBuzz) {
socket.emit("buzz");
}
},
1000,
{ leading: true, trailing: false }
);
return (
<div>
<svg viewBox={`0 0 100 100`}>
<circle
cx={50}
cy={50}
r={30}
fill={canBuzz ? "red" : "grey"}
stroke="black"
onClick={handleBuzz}
/>
<text x={50} y={50} textAnchor="middle">
Buzz
</text>
</svg>
</div>
);
};
export default Contestant;

27
src/ContestantWidget.tsx Normal file
View File

@ -0,0 +1,27 @@
import DrawPad from "./DrawPad";
import type { Contestant } from "./store/displaySlice";
interface Props {
contestant: Contestant;
}
const ContestantWidget = ({ contestant }: Props) => (
<span style={{ minWidth: "30%" }}>
<h2 className="text-center">${String(contestant.score)}</h2>
<div
style={{
padding: 5,
backgroundColor: contestant.active
? "yellow"
: contestant.failed
? "red"
: "black",
minWidth: "30%",
}}
>
<DrawPad lines={contestant.signature} height={200} readonly />
</div>
</span>
);
export default ContestantWidget;

36
src/Display.tsx Normal file
View File

@ -0,0 +1,36 @@
import { useEffect } from "react";
import { Stack, Container } from "react-bootstrap";
import { socket } from "./socket";
import { useAppSelector } from "./hooks";
import { selectRoomCode } from "./store/socketSlice";
import { selectContestants } from "./store/displaySlice";
import ContestantWidget from "./ContestantWidget";
const Display = () => {
const roomCode = useAppSelector(selectRoomCode);
const contestants = useAppSelector(selectContestants);
useEffect(() => {
socket.emit("new-game");
}, []);
return (
<>
<Container>
<h1 className="text-center">Room code is: {roomCode}</h1>
</Container>
<Stack
direction="horizontal"
gap={10}
className="position-absolute bottom-0 w-100 d-flex justify-content-center"
>
{Object.entries(contestants).map(([sid, contestant]) => (
<ContestantWidget key={sid} contestant={contestant} />
))}
</Stack>
</>
);
};
export default Display;

83
src/DrawPad.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useRef, useState, useEffect } from "react";
type Props = {
height: number,
lines: number[][],
onUpdateImage?: (path: number[]) => unknown,
readonly?: boolean,
};
const cursorPos = (e) => {
const rect = e.target.getBoundingClientRect();
return [e.clientX - rect.left, e.clientY - rect.top];
};
const DrawPad = ({ height, lines, onUpdateImage, readonly = false }: Props) => {
const parent = useRef();
const canvas = useRef();
const [width, setWidth] = useState(0);
const [down, setDown] = useState(false);
const [path, setPath] = useState([]);
const pen = canvas.current?.getContext("2d");
const render = () => {
if (pen) {
pen.clearRect(0, 0, width, height);
pen.fillStyle = "blue";
pen.fillRect(0, 0, width, height);
pen.lineWidth = 3;
pen.lineCap = "round";
pen.strokeStyle = "white";
lines.forEach((path) => {
if (path.length > 2) {
pen.moveTo(path[0], path[1]);
}
for (let i = 2; i < path.length; i += 2) {
pen.lineTo(path[i], path[i + 1]);
pen.stroke();
}
});
}
};
useEffect(() => {
setWidth(parent.current?.getBoundingClientRect().width);
render();
}, [parent, pen, lines]);
const beginLine = () => {
if (readonly) return;
pen.beginPath();
setDown(true);
};
const drawLine = (e) => {
if (!down) return;
const [x, y] = cursorPos(e);
setPath([...path, x, y]);
pen.lineTo(x, y);
pen.stroke();
};
const endLine = () => {
setDown(false);
onUpdateImage?.(path);
setPath([]);
};
return (
<div ref={parent}>
<canvas
width={width}
height={height}
ref={canvas}
onPointerDown={beginLine}
onPointerMove={drawLine}
onPointerUp={endLine}
style={{ touchAction: "none" }}
/>
</div>
);
};
export default DrawPad;

12
src/Error.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Container } from "react-bootstrap";
const Error = () => (
<Container className="text-center">
<h1>Oops!</h1>
<p>
I don&apos;t think you meant to come here, why don&apos;t you go back?
</p>
</Container>
);
export default Error;

83
src/Landing.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Container, Stack, Button, Modal, Form } from "react-bootstrap";
import { useAppDispatch, useAppSelector } from "./hooks";
import { addPathToSignature, selectSignature } from "./store/contestantSlice";
import DrawPad from "./DrawPad";
const Landing = () => {
const [isContestant, setIsContestant] = useState(false);
const [isHost, setIsHost] = useState(false);
const [roomCode, setRoomCode] = useState("");
const dispatch = useAppDispatch();
const signature = useAppSelector(selectSignature);
return (
<>
<Container>
<Stack gap={3} className="text-center">
<h1>Venture</h1>
<Link to={"/new-game"}>
<Button>New Game</Button>
</Link>
<span>
<Button onClick={() => setIsContestant(true)}>
Join as Contestant
</Button>
</span>
<span>
<Button onClick={() => setIsHost(true)}>Join as Host</Button>
</span>
</Stack>
</Container>
<Modal show={isContestant}>
<Modal.Body>
<Form className="text-center">
<Form.Group className="text-start">
<Form.Label>Room Code</Form.Label>
<Form.Control
type={"text"}
value={roomCode}
onChange={(e) => setRoomCode(e.target.value)}
/>
</Form.Group>
<Form.Group className="text-start">
<Form.Label>Name</Form.Label>
<DrawPad
height={200}
lines={signature}
onUpdateImage={(path: number[]) =>
dispatch(addPathToSignature(path))
}
/>
</Form.Group>
<Link to={`/contestant-join/${roomCode}`}>
<Button className="m-3">Submit</Button>
</Link>
</Form>
</Modal.Body>
</Modal>
<Modal show={isHost}>
<Modal.Body>
<Form className="text-center">
<Form.Group className="text-start">
<Form.Label>Room Code</Form.Label>
<Form.Control
type={"text"}
value={roomCode}
onChange={(e) => setRoomCode(e.target.value)}
/>
</Form.Group>
<Link to={`/host-join/${roomCode}`}>
<Button className="m-3">Submit</Button>
</Link>
</Form>
</Modal.Body>
</Modal>
</>
);
};
export default Landing;

5
src/hooks.ts Normal file
View File

@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

12
src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { setup } from './socket';
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
setup();

20
src/socket.ts Normal file
View File

@ -0,0 +1,20 @@
import { io } from "socket.io-client";
import store from "./store";
import { setRoomCode } from "./store/socketSlice";
import { addContestant } from "./store/displaySlice";
export const socket = io(`${window.location.hostname}:5000`);
export const setup = () => {
socket.on("connect", () => {
console.log("Connected to socket");
});
socket.on("set-code", ({ code }) => {
store.dispatch(setRoomCode(code));
});
socket.on("contestant-joined", (data) => {
store.dispatch(addContestant(data));
});
};

View File

@ -0,0 +1,31 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { RootState } from "./";
interface ContestantState {
signature: number[][];
canBuzz: boolean;
}
const initialState: ContestantState = {
signature: [],
canBuzz: false,
};
export const contestantSlice = createSlice({
name: "contestant",
initialState,
reducers: {
addPathToSignature: (state, { payload }: PayloadAction<number[]>) => {
state.signature = [...state.signature, payload];
},
setCanBuzz: (state, { payload }: PayloadAction<boolean>) => {
state.canBuzz = payload;
},
},
});
export const { addPathToSignature } = contestantSlice.actions;
export const selectSignature = (state: RootState) => state.contestant.signature;
export const selectCanBuzz = (state: RootState) => state.contestant.canBuzz;
export default contestantSlice.reducer;

41
src/store/displaySlice.ts Normal file
View File

@ -0,0 +1,41 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { RootState } from "./";
interface DisplayState {
contestants: Record<string, Contestant>;
}
interface Contestant {
signature: number[][];
score: number;
active: boolean;
failed: boolean;
}
const initialState: DisplayState = {
contestants: {},
};
export const displaySlice = createSlice({
name: "display",
initialState,
reducers: {
addContestant: (
state,
{
payload: { sid, signature },
}: PayloadAction<{ sid: string; signature: number[][] }>
) => {
state.contestants = {
...state.contestants,
[sid]: { signature, score: 0, active: false, failed: false },
};
},
},
});
export const { addContestant } = displaySlice.actions;
export const selectContestants = (state: RootState) =>
state.display.contestants;
export default displaySlice.reducer;

17
src/store/index.ts Normal file
View File

@ -0,0 +1,17 @@
import { configureStore } from "@reduxjs/toolkit";
import socketReducer from "./socketSlice";
import contestantReducer from "./contestantSlice";
import displayReducer from "./displaySlice";
const store = configureStore({
reducer: {
socket: socketReducer,
contestant: contestantReducer,
display: displayReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

25
src/store/socketSlice.ts Normal file
View File

@ -0,0 +1,25 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { RootState } from "./";
interface SocketState {
roomCode: string;
}
const initialState: SocketState = {
roomCode: "",
};
export const socketSlice = createSlice({
name: "socket",
initialState,
reducers: {
setRoomCode: (state, { payload }: PayloadAction<string>) => {
state.roomCode = payload;
},
},
});
export const { setRoomCode } = socketSlice.actions;
export const selectRoomCode = (state: RootState) => state.socket.roomCode;
export default socketSlice.reducer;

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import eslint from 'vite-plugin-eslint';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
{...react(), ...eslint(), apply: 'build'},
{...react(), ...eslint({ failOnWarning: false, failOnError: false }), apply: 'serve', enforce: 'post'},
],
});