client-next (react rewrite)

This commit is contained in:
Grant 2024-02-07 21:39:02 -07:00
parent cc94580bbe
commit 2e469e39a9
22 changed files with 7371 additions and 1779 deletions

2
.gitignore vendored
View File

@ -1,6 +1,8 @@
packages/server/prisma/dev.db
packages/client/public/**/*.css
packages/client/public/**/*.js
packages/client-next/public/**/*.css
packages/client-next/public/**/*.js
# Logs
logs

8186
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "1.0.0",
"description": "",
"workspaces": [
"packages/client",
"packages/client-next",
"packages/server",
"packages/lib"
],

View File

@ -0,0 +1,7 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-class-properties"
]
}

View File

@ -0,0 +1,26 @@
var gulp = require("gulp");
var ts = require("gulp-typescript");
const webpack = require("webpack-stream");
const sass = require("gulp-sass")(require("sass"));
gulp.task("css", function () {
return gulp
.src("src/style.scss")
.pipe(sass().on("error", sass.logError))
.pipe(gulp.dest("./public/css"));
});
gulp.task("js", function () {
return gulp
.src("src/**/*.tsx?")
.pipe(webpack(require("./webpack.config.js")))
.pipe(gulp.dest("public"));
});
gulp.task(
"watch",
gulp.series("js", "css", function () {
gulp.watch(["src/**/*.ts", "src/**/*.tsx"], gulp.series("js"));
gulp.watch("src/**/*.scss", gulp.series("css"));
})
);

View File

@ -0,0 +1,55 @@
{
"name": "client",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"eslintConfig": {
"extends": "react-app"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@sc07-canvas/lib": "^1.0.0",
"eventemitter3": "^5.0.1",
"lodash.throttle": "^4.1.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-zoom-pan-pinch": "^3.4.1",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "^7.23.9",
"@babel/preset-react": "^7.23.3",
"@types/lodash.throttle": "^4.1.9",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/socket.io-client": "^3.0.0",
"babel-loader": "^9.1.3",
"css-loader": "^6.9.1",
"eslint": "^8.56.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"gulp": "^4.0.2",
"gulp-cli": "^2.3.0",
"gulp-typescript": "^6.0.0-alpha.1",
"html-webpack-plugin": "^5.6.0",
"sass": "^1.70.0",
"style-loader": "^3.3.4",
"ts-loader": "^9.5.1",
"webpack": "^5.90.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
}
}

View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>webpack-for-react</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>

View File

@ -0,0 +1,17 @@
import React from "react";
import { Header } from "./Header";
import { AppContext } from "../contexts/AppContext";
import { CanvasWrapper } from "./CanvasWrapper";
import { Pallete } from "./Pallete";
const App = () => {
return (
<AppContext>
<Header />
<CanvasWrapper />
<Pallete />
</AppContext>
);
};
export default App;

View File

@ -0,0 +1,77 @@
import React, { createRef, useEffect } from "react";
import {
TransformComponent,
TransformWrapper,
useControls,
useTransformEffect,
} from "react-zoom-pan-pinch";
import { Canvas } from "../lib/canvas";
import { useAppContext } from "../contexts/AppContext";
import throttle from "lodash.throttle";
export const CanvasWrapper = () => {
// to prevent safari from blurring things, use the zoom css property
return (
<main>
<TransformWrapper
centerOnInit
limitToBounds={false}
centerZoomedOut={false}
minScale={0.5}
maxScale={50}
wheel={{
step: 0.05,
smoothStep: 0.05,
}}
initialScale={5}
panning={{
velocityDisabled: true,
}}
doubleClick={{
disabled: true,
}}
>
<TransformComponent wrapperStyle={{ width: "100%", height: "100%" }}>
<CanvasInner />
</TransformComponent>
</TransformWrapper>
</main>
);
};
const CanvasInner = () => {
const canvasRef = createRef<HTMLCanvasElement>();
const { config } = useAppContext();
const { centerView } = useControls();
useTransformEffect(
throttle(({ state, instance }) => {
const params = new URLSearchParams();
params.set("x", state.positionX + "");
params.set("y", state.positionY + "");
params.set("zoom", state.scale + "");
window.location.hash = params.toString();
}, 1000)
);
useEffect(() => {
if (!config.canvas || !canvasRef.current) return;
const canvas = canvasRef.current!;
const canvasInstance = new Canvas(config, canvas);
centerView();
return () => {
canvasInstance.destroy();
};
}, [canvasRef, centerView, config]);
return (
<canvas
id="board"
width="1000"
height="1000"
className="pixelate"
ref={canvasRef}
></canvas>
);
};

View File

@ -0,0 +1,19 @@
import React from "react";
export const Header = () => {
return (
<header>
<div></div>
<div className="spacer"></div>
<div>
<div className="user-card">
<div className="user-card--overview">
<div className="user-name"></div>
<div className="user-instance"></div>
</div>
<img src="#" alt="User Avatar" className="user-avatar" />
</div>
</div>
</header>
);
};

View File

@ -0,0 +1,78 @@
#pallete {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
display: flex;
gap: 10px;
padding: 10px;
background-color: #fff;
.pallete-colors {
// display: flex;
// width: 100%;
// justify-content: center;
// gap: 10px;
flex-grow: 1;
text-align: center;
padding: 10px 0;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
.pallete-color {
margin-left: 10px;
}
}
.pallete-color--deselect {
display: flex-inline;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
cursor: pointer;
vertical-align: top;
font-size: 2rem;
line-height: 1;
color: #000;
border: 0;
background: transparent;
}
.pallete-color {
width: 36px;
height: 36px;
border: 2px solid #000;
border-radius: 3px;
transition: transform 0.25s;
cursor: pointer;
&:hover {
transform: scale(1.1);
}
&.active {
transform: scale(1.3);
}
}
.pallete-user-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@ -0,0 +1,81 @@
import React, { useEffect, useState } from "react";
import { useAppContext } from "../contexts/AppContext";
import { Canvas } from "../lib/canvas";
import { IPalleteContext } from "../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
export const Pallete = () => {
const { config, user } = useAppContext();
const [pallete, setPallete] = useState<IPalleteContext>({});
useEffect(() => {
if (!Canvas.instance) return;
Canvas.instance.emit("pallete", pallete);
}, [pallete]);
return (
<div id="pallete">
<CanvasMeta />
<div className="pallete-colors">
<button
aria-label="Deselect Color"
className="pallete-color--deselect"
title="Deselect Color"
onClick={() => {
setPallete(({ color, ...pallete }) => {
return pallete;
});
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
{config.pallete.colors.map((color) => (
<button
key={color.id}
aria-label={color.name}
className={["pallete-color", color.id === pallete.color && "active"]
.filter((a) => a)
.join(" ")}
style={{
backgroundColor: "#" + color.hex,
}}
title={color.name}
onClick={() => {
setPallete((pallete) => {
return {
...pallete,
color: color.id,
};
});
}}
></button>
))}
</div>
{!user && (
<div className="pallete-user-overlay">
You are not logged in
<a href="/api/login" className="user-login">
Login
</a>
</div>
)}
</div>
);
};
export const CanvasMeta = () => {
return (
<div id="canvas-meta">
<span>
Pixels: <span>123</span>
</span>
<span>
Users Online: <span>321</span>
</span>
</div>
);
};

View File

@ -0,0 +1,5 @@
import React from "react";
export const PanZoomWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="test-wrapper">{children}</div>;
};

View File

@ -0,0 +1,48 @@
import React, {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { Socket } from "socket.io-client";
import { ClientConfig, IAppContext, IPalleteContext } from "../types";
import { AuthSession } from "@sc07-canvas/lib/src/net";
import { number } from "prop-types";
import Network from "../lib/network";
const appContext = createContext<IAppContext>({} as any);
export const useAppContext = () => useContext(appContext);
export const AppContext = ({ children }: PropsWithChildren) => {
const [config, setConfig] = useState<ClientConfig>(undefined as any);
const [auth, setAuth] = useState<AuthSession>();
useEffect(() => {
function handleConfig(config: ClientConfig) {
console.info("Server sent config", config);
setConfig(config);
}
function handleUser(user: AuthSession) {
setAuth(user);
}
Network.on("user", handleUser);
Network.on("config", handleConfig);
Network.socket.connect();
return () => {
Network.off("user", handleUser);
Network.off("config", handleConfig);
};
}, []);
return (
<appContext.Provider value={{ config, user: auth }}>
{config ? children : "Loading..."}
</appContext.Provider>
);
};

View File

@ -0,0 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import App from "./components/App";
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

View File

@ -0,0 +1,221 @@
import EventEmitter from "eventemitter3";
import { ClientConfig, IPalleteContext, Pixel } from "../types";
import Network from "./network";
export class Canvas extends EventEmitter {
static instance: Canvas | undefined;
private _destroy = false;
private config: ClientConfig;
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private cursor = { x: -1, y: -1 };
private pixels: {
[x_y: string]: { color: number; type: "full" | "pending" };
} = {};
private lastPlace: number | undefined;
constructor(config: ClientConfig, canvas: HTMLCanvasElement) {
super();
Canvas.instance = this;
this.config = config;
this.canvas = canvas;
this.ctx = canvas.getContext("2d")!;
canvas.width = config.canvas.size[0];
canvas.height = config.canvas.size[1];
canvas.addEventListener("mousemove", this.handleMouseMove.bind(this));
canvas.addEventListener("mouseup", this.handleMouseClick.bind(this));
canvas.addEventListener("mousedown", this.handleMouseDown.bind(this));
this.on("pallete", this.updatePallete.bind(this));
// Network.on("canvas", this.handleBatch.bind(this));
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
this.draw();
}
destroy() {
this._destroy = true;
this.canvas.removeEventListener(
"mousemove",
this.handleMouseMove.bind(this)
);
this.canvas.removeEventListener(
"mouseup",
this.handleMouseClick.bind(this)
);
this.canvas.removeEventListener(
"mousedown",
this.handleMouseDown.bind(this)
);
Network.off("canvas", this.handleBatch.bind(this));
}
private downTime: number | undefined;
private dragOrigin: { x: number; y: number } = { x: 0, y: 0 };
handleMouseClick(e: MouseEvent) {
const downDelta = Date.now() - this.downTime!;
const delta = [
Math.abs(this.dragOrigin.x - e.clientX),
Math.abs(this.dragOrigin.y - e.clientY),
];
if (downDelta < 500) {
// mouse was down for less than 500ms
if (delta[0] < 5 && delta[1] < 5) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.place(x, y);
}
}
}
handleMouseDown(e: MouseEvent) {
this.downTime = Date.now();
this.dragOrigin = { x: e.pageX, y: e.pageY };
}
handleMouseMove(e: MouseEvent) {
const canvasRect = this.canvas.getBoundingClientRect();
if (
canvasRect.left <= e.pageX &&
canvasRect.right >= e.pageX &&
canvasRect.top <= e.pageY &&
canvasRect.bottom >= e.pageY
) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
this.cursor.x = x;
this.cursor.y = y;
} else {
this.cursor.x = -1;
this.cursor.y = -1;
}
}
handleBatch(pixels: string[]) {
pixels.forEach((hex, index) => {
const x = index / this.config.canvas.size[0];
const y = index % this.config.canvas.size[0];
const color = this.Pallete.getColorFromHex(hex);
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
};
});
}
handlePixel({ x, y, color, ...pixel }: Pixel) {
this.pixels[x + "_" + y] = {
color,
type: "full",
};
}
palleteCtx: IPalleteContext = {};
Pallete = {
getColor: (colorId: number) => {
return this.config.pallete.colors.find((c) => c.id === colorId);
},
getSelectedColor: () => {
if (!this.palleteCtx.color) return undefined;
return this.Pallete.getColor(this.palleteCtx.color);
},
getColorFromHex: (hex: string) => {
return this.config.pallete.colors.find((c) => c.hex === hex);
},
};
updatePallete(pallete: IPalleteContext) {
this.palleteCtx = pallete;
}
place(x: number, y: number) {
if (!this.Pallete.getSelectedColor()) return;
if (this.lastPlace) {
if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
console.log("cannot place; cooldown");
return;
}
}
this.lastPlace = Date.now();
Network.socket
.emitWithAck("place", {
x,
y,
color: this.Pallete.getSelectedColor()!.id,
})
.then((ack) => {
if (ack.success) {
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("error: " + ack.error);
}
});
}
screenToPos(x: number, y: number) {
const rect = this.canvas.getBoundingClientRect();
const scale = [
this.canvas.width / rect.width,
this.canvas.height / rect.height,
];
return [x - rect.left, y - rect.top]
.map((v, i) => v * scale[i])
.map((v) => v >> 0);
}
draw() {
this.ctx.imageSmoothingEnabled = false;
const bezier = (n: number) => n * n * (3 - 2 * n);
this.ctx.globalAlpha = 1;
this.ctx.fillStyle = "#fff";
this.ctx.fillRect(
0,
0,
this.config.canvas.size[0],
this.config.canvas.size[1]
);
for (const [x_y, pixel] of Object.entries(this.pixels)) {
const [x, y] = x_y.split("_").map((a) => parseInt(a));
this.ctx.globalAlpha = pixel.type === "full" ? 1 : 0.5;
this.ctx.fillStyle =
pixel.color > -1
? "#" + this.Pallete.getColor(pixel.color)!.hex
: "transparent";
this.ctx.fillRect(x, y, 1, 1);
}
if (this.palleteCtx.color && this.cursor.x > -1 && this.cursor.y > -1) {
const color = this.config.pallete.colors.find(
(c) => c.id === this.palleteCtx.color
);
let t = ((Date.now() / 100) % 10) / 10;
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
this.ctx.fillStyle = "#" + color!.hex;
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
}
if (!this._destroy) window.requestAnimationFrame(() => this.draw());
}
}

View File

@ -0,0 +1,88 @@
import { Socket, io } from "socket.io-client";
import {
ClientConfig,
ClientToServerEvents,
ServerToClientEvents,
} from "../types";
import EventEmitter from "eventemitter3";
import { AuthSession } from "@sc07-canvas/lib/src/net";
export interface INetworkEvents {
user: (user: AuthSession) => void;
config: (user: ClientConfig) => void;
canvas: (pixels: string[]) => void;
}
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
Exclude<INetworkEvents, string | symbol>
>[Extract<K, keyof INetworkEvents>];
class Network extends EventEmitter<INetworkEvents> {
socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
autoConnect: false,
});
private online_count = 0;
private sentEvents: {
[key in keyof INetworkEvents]?: SentEventValue<key>;
} = {};
constructor() {
super();
this.socket.on("user", (user: AuthSession) => {
this.emit("user", user);
});
this.socket.on("config", (config) => {
this.emit("config", config);
});
this.socket.on("canvas", (pixels) => {
this._emit("canvas", pixels);
});
// this.socket.on("config", (config) => {
// Pallete.load(config.pallete);
// Canvas.load(config.canvas);
// });
// this.socket.on("pixel", (data: SPixelPacket) => {
// Canvas.handlePixel(data);
// });
// this.socket.on("canvas", (data: SCanvasPacket) => {
// Canvas.handleBatch(data);
// });
// this.socket.on("online", (data: { count: number }) => {
// this.online_count = data.count;
// });
}
private _emit: typeof this.emit = (event, ...args) => {
this.sentEvents[event] = args;
return this.emit(event, ...args);
};
waitFor<Ev extends keyof INetworkEvents & (string | symbol)>(
ev: Ev
): Promise<SentEventValue<Ev>> {
return new Promise((res) => {
if (this.sentEvents[ev]) return res(this.sentEvents[ev]!);
this.once(ev, (...data) => {
res(data);
});
});
}
/**
* Get online user count
* @returns online users count
*/
getOnline() {
return this.online_count;
}
}
export default new Network() as Network;

View File

@ -0,0 +1,118 @@
* {
box-sizing: border-box;
}
html,
body {
overscroll-behavior: contain;
touch-action: none;
}
body {
background-color: #ddd;
}
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
display: flex;
flex-direction: row;
padding: 10px;
box-sizing: border-box;
z-index: 9999;
.spacer {
flex-grow: 1;
}
}
.user-card {
background-color: #444;
color: #fff;
border-radius: 10px;
padding: 5px 10px;
display: flex;
flex-direction: row;
gap: 10px;
&--overview {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1;
span:first-of-type {
font-size: 130%;
margin-bottom: 5px;
}
}
img {
width: 64px;
height: 64px;
background-color: #aaa;
border-radius: 50%;
}
}
#cursor {
position: fixed;
top: 20px;
left: 10px;
width: 36px;
height: 36px;
border: 2px solid #000;
border-radius: 3px;
pointer-events: none;
will-change: transform;
z-index: 2;
}
#canvas-meta {
position: absolute;
top: -10px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 5px;
padding: 5px;
transform: translateY(-100%);
display: flex;
flex-direction: column;
}
main {
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
canvas {
display: block;
box-sizing: border-box;
}
}
.pixelate {
image-rendering: optimizeSpeed;
-ms-interpolation-mode: nearest-neighbor;
image-rendering: -webkit-optimize-contrast;
image-rendering: -webkit-crisp-edges;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
@import "./components/Pallete.scss";

View File

@ -0,0 +1,52 @@
import { AuthSession, PacketAck } from "@sc07-canvas/lib/src/net";
// socket.io
export interface ServerToClientEvents {
canvas: (pixels: string[]) => void;
user: (user: AuthSession) => void;
config: (config: ClientConfig) => void;
pixel: (pixel: Pixel) => void;
}
export interface ClientToServerEvents {
place: (pixel: Pixel, ack: (_: PacketAck<Pixel>) => void) => void;
}
// app context
export interface IAppContext {
config: ClientConfig;
user?: AuthSession;
}
export interface IPalleteContext {
color?: number;
}
// other
export type Pixel = {
x: number;
y: number;
color: number;
};
export type PalleteColor = {
id: number;
name: string;
hex: string;
};
export type CanvasConfig = {
size: [number, number];
zoom: number;
};
export type ClientConfig = {
pallete: {
colors: PalleteColor[];
pixel_cooldown: number;
};
canvas: CanvasConfig;
};

View File

@ -0,0 +1,7 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
},
}

View File

@ -0,0 +1,27 @@
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
const port = process.env.PORT || 3000;
module.exports = {
mode: "development",
entry: "./src/index.tsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "public"),
},
devtool: "inline-source-map",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
};

View File

@ -50,7 +50,7 @@ const io = new Server<
user: (user: SUserPacket) => void;
config: (config: any) => void;
pixel: (data: SPixelPacket) => void;
canvas: (data: SCanvasPacket) => void;
canvas: (pixels: string[]) => void;
online: (data: { count: number }) => void;
}
>(server);
@ -109,11 +109,7 @@ io.on("connection", (socket) => {
});
Canvas.getPixelsArray().then((pixels) => {
socket.emit("canvas", {
_direction: "server->client",
type: "canvas",
pixels,
});
socket.emit("canvas", pixels);
});
socket.on(
@ -182,7 +178,7 @@ io.on("connection", (socket) => {
});
app.use(session);
app.use(express.static("../client/public"));
app.use(express.static("../client-next/public"));
app.use("/api", APIRoutes);
server.listen(parseInt(process.env.PORT!), () => {