canvas resizing (related #12)

- updated client to allow for canvas size to change
- added API routes for admin UI to change size
- added isAdmin flag to user accounts
This commit is contained in:
Grant 2024-05-28 20:34:59 -06:00
parent 8559aea7c3
commit ad1a785451
23 changed files with 539 additions and 78 deletions

53
package-lock.json generated
View File

@ -16242,7 +16242,9 @@
"dependencies": {
"@prisma/client": "^5.3.1",
"@sc07-canvas/lib": "^1.0.0",
"body-parser": "^1.20.2",
"connect-redis": "^7.1.1",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-session": "^1.17.3",
"openid-client": "^5.6.5",
@ -16253,6 +16255,7 @@
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
"@typescript-eslint/eslint-plugin": "^7.1.0",
@ -16455,6 +16458,42 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"packages/server/node_modules/body-parser": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"packages/server/node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"packages/server/node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"packages/server/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -16501,6 +16540,20 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"packages/server/node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
}
}
}

View File

@ -0,0 +1,9 @@
import { Spinner } from "@nextui-org/react";
export const LoadingOverlay = () => {
return (
<div className="absolute top-0 left-0 w-full h-full z-[9999] backdrop-blur-sm bg-black/30 text-white flex items-center justify-center">
<Spinner label="Loading..." />
</div>
);
};

View File

@ -124,10 +124,10 @@ export const SidebarWrapper = () => {
href="/chat/rooms"
/>
<SidebarItem
isActive={pathname === "/chat/settings"}
isActive={pathname === "/service/settings"}
title="Settings"
icon={<FontAwesomeIcon icon={faCog} />}
href="/chat/settings"
href="/service/settings"
/>
</SidebarMenu>
</div>

View File

@ -0,0 +1,34 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const api = async <T = any>(
endpoint: string,
method: "GET" | "POST" = "GET",
body?: unknown
): Promise<{
status: number;
data: ({ success: true } & T) | { success: false; error: string };
}> => {
const API_HOST = import.meta.env.VITE_API_ROOT || "";
const req = await fetch(API_HOST + endpoint, {
method,
credentials: "include",
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
},
body: JSON.stringify(body),
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
data = await req.json();
} catch (e) {
/* empty */
}
return {
status: req.status,
data,
};
};

View File

@ -7,6 +7,7 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { Root } from "./Root.tsx";
import { HomePage } from "./pages/Home/page.tsx";
import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx";
import { ServiceSettingsPage } from "./pages/Service/settings.tsx";
const router = createBrowserRouter(
[
@ -22,6 +23,10 @@ const router = createBrowserRouter(
path: "/accounts",
element: <AccountsPage />,
},
{
path: "/service/settings",
element: <ServiceSettingsPage />,
},
],
},
],

View File

@ -0,0 +1,97 @@
import { BreadcrumbItem, Breadcrumbs, Button, Input } from "@nextui-org/react";
import { useEffect, useState } from "react";
import { api } from "../../lib/utils";
import { LoadingOverlay } from "../../components/LoadingOverlay";
export const ServiceSettingsPage = () => {
return (
<div className="my-14 lg:px-6 max-w-[95rem] mx-auto w-full flex flex-col gap-4">
<Breadcrumbs>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem>Service</BreadcrumbItem>
<BreadcrumbItem>Settings</BreadcrumbItem>
</Breadcrumbs>
<h3 className="text-xl font-semibold">Service Settings</h3>
<CanvasSettings />
</div>
);
};
const CanvasSettings = () => {
const [loading, setLoading] = useState(true);
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
useEffect(() => {
api<{ size: { width: number; height: number } }>("/api/admin/canvas/size")
.then(({ status, data }) => {
if (status === 200) {
if (data.success) {
setWidth(data.size.width + "");
setHeight(data.size.height + "");
} else {
console.error(status, data);
}
} else {
console.error(status, data);
}
})
.finally(() => {
setLoading(false);
});
}, []);
const doSaveSize = () => {
setLoading(true);
api("/api/admin/canvas/size", "POST", {
width,
height,
})
.then(({ status, data }) => {
if (status === 200) {
if (data.success) {
alert("good");
} else {
console.error(status, data);
}
} else {
console.error(status, data);
}
})
.finally(() => {
setLoading(false);
});
};
return (
<>
<h4 className="text-l font-semibold">Canvas</h4>
<div className="relative">
{loading && <LoadingOverlay />}
<Input
type="number"
size="sm"
min="100"
max="10000"
label="Width"
value={width}
onValueChange={setWidth}
/>
<Input
type="number"
size="sm"
min="100"
max="10000"
label="Height"
value={height}
onValueChange={setHeight}
/>
<Button onPress={doSaveSize} isLoading={loading}>
Save
</Button>
</div>
</>
);
};

View File

@ -1,4 +1,4 @@
import { createRef, useContext, useEffect } from "react";
import { useCallback, useContext, useEffect, useRef } from "react";
import { Canvas } from "../lib/canvas";
import { useAppContext } from "../contexts/AppContext";
import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer";
@ -24,23 +24,82 @@ export const CanvasWrapper = () => {
};
const CanvasInner = () => {
const canvasRef = createRef<HTMLCanvasElement>();
const canvasRef = useRef<HTMLCanvasElement | null>();
const canvas = useRef<Canvas>();
const { config, setCanvasPosition, setCursorPosition } = useAppContext();
const PanZoom = useContext(RendererContext);
useEffect(() => {
if (!canvasRef.current) return;
canvas.current = new Canvas(canvasRef.current!, PanZoom);
return () => {
canvas.current!.destroy();
};
}, [PanZoom, setCursorPosition]);
useEffect(() => {
Router.PanZoom = PanZoom;
}, [PanZoom]);
useEffect(() => {
if (!config?.canvas || !canvasRef.current) return;
const canvas = canvasRef.current!;
const canvasInstance = new Canvas(config, canvas, PanZoom);
const initAt = Date.now();
if (!canvas.current) {
console.warn("canvas isntance doesn't exist");
return;
}
const handleNavigate = (data: IRouterData) => {
const handleCursorPos = throttle((pos: IPosition) => {
if (!canvas.current?.hasConfig() || !config) {
console.warn("handleCursorPos has no config");
return;
}
if (
pos.x < 0 ||
pos.y < 0 ||
pos.x > config.canvas.size[0] ||
pos.y > config.canvas.size[1]
) {
setCursorPosition();
} else {
// fixes not passing the current value
setCursorPosition({ ...pos });
}
}, 1);
canvas.current.on("cursorPos", handleCursorPos);
return () => {
canvas.current!.off("cursorPos", handleCursorPos);
};
}, [config, setCursorPosition]);
useEffect(() => {
if (!canvas.current) {
console.warn("canvasinner config received but no canvas instance");
return;
}
if (!config) {
console.warn("canvasinner config received falsey");
return;
}
console.log("[CanvasInner] config updated, informing canvas instance");
canvas.current.loadConfig(config);
// refresh because canvas might've resized
const initialRouter = Router.get();
console.log(
"[CanvasWrapper] Config updated, triggering navigate",
initialRouter
);
handleNavigate(initialRouter);
}, [config]);
const handleNavigate = useCallback(
(data: IRouterData) => {
if (data.canvas) {
const position = canvasInstance.canvasToPanZoomTransform(
const position = canvas.current!.canvasToPanZoomTransform(
data.canvas.x,
data.canvas.y
);
@ -54,7 +113,15 @@ const CanvasInner = () => {
{ suppressEmit: true }
);
}
};
},
[PanZoom]
);
useEffect(() => {
// if (!config?.canvas || !canvasRef.current) return;
// const canvas = canvasRef.current!;
// const canvasInstance = new Canvas(canvas, PanZoom);
const initAt = Date.now();
// initial load
const initialRouter = Router.get();
@ -75,33 +142,14 @@ const CanvasInner = () => {
Router.queueUpdate();
};
const handleCursorPos = throttle((pos: IPosition) => {
if (
pos.x < 0 ||
pos.y < 0 ||
pos.x > config.canvas.size[0] ||
pos.y > config.canvas.size[1]
) {
setCursorPosition();
} else {
// fixes not passing the current value
setCursorPosition({ ...pos });
}
}, 1);
PanZoom.addListener("viewportMove", handleViewportMove);
canvasInstance.on("cursorPos", handleCursorPos);
Router.on("navigate", handleNavigate);
return () => {
canvasInstance.destroy();
PanZoom.removeListener("viewportMove", handleViewportMove);
canvasInstance.off("cursorPos", handleCursorPos);
Router.off("navigate", handleNavigate);
};
// ! do not include canvasRef, it causes infinite re-renders
}, [PanZoom, config, setCanvasPosition, setCursorPosition]);
}, [PanZoom, setCanvasPosition, setCursorPosition]);
return (
<canvas
@ -109,7 +157,7 @@ const CanvasInner = () => {
width="1000"
height="1000"
className="pixelate"
ref={canvasRef}
ref={(ref) => (canvasRef.current = ref)}
></canvas>
);
};

View File

@ -1,4 +1,4 @@
import { Button, Card, CardBody } from "@nextui-org/react";
import { Button, Card, CardBody, Link } from "@nextui-org/react";
import { useAppContext } from "../contexts/AppContext";
import { User } from "./Header/User";
import { Debug } from "@sc07-canvas/lib/src/debug";
@ -13,7 +13,7 @@ const DynamicChat = () => {
};
export const Header = () => {
const { setSettingsSidebar, connected } = useAppContext();
const { setSettingsSidebar, connected, hasAdmin } = useAppContext();
return (
<header id="main-header">
@ -31,6 +31,11 @@ export const Header = () => {
<User />
<Button onClick={() => setSettingsSidebar(true)}>Settings</Button>
<Button onClick={() => Debug.openDebugTools()}>debug</Button>
{hasAdmin && (
<Button href="/admin" target="_blank" as={Link}>
Admin
</Button>
)}
<DynamicChat />
</div>
</header>

View File

@ -14,6 +14,7 @@ import {
} from "@sc07-canvas/lib/src/net";
import Network from "../lib/network";
import { Spinner } from "@nextui-org/react";
import { api } from "../lib/utils";
const appContext = createContext<IAppContext>({} as any);
@ -35,6 +36,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
// overlays visible
const [settingsSidebar, setSettingsSidebar] = useState(false);
const [hasAdmin, setHasAdmin] = useState(false);
useEffect(() => {
function loadSettings() {
setLoadChat(
@ -74,6 +77,14 @@ export const AppContext = ({ children }: PropsWithChildren) => {
setConnected(false);
}
api<{}>("/api/admin/check").then(({ status, data }) => {
if (status === 200) {
if (data.success) {
setHasAdmin(true);
}
}
});
Network.on("user", handleUser);
Network.on("config", handleConfig);
Network.waitFor("pixels").then(([data]) => handlePixels(data));
@ -118,6 +129,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
loadChat,
setLoadChat,
connected,
hasAdmin,
}}
>
{!config && (

View File

@ -11,6 +11,7 @@ import {
HoverEvent,
PanZoom,
} from "@sc07-canvas/lib/src/renderer/PanZoom";
import { toast } from "react-toastify";
interface CanvasEvents {
/**
@ -26,7 +27,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
static instance: Canvas | undefined;
private _destroy = false;
private config: ClientConfig;
private config: ClientConfig = {} as any;
private canvas: HTMLCanvasElement;
private PanZoom: PanZoom;
private ctx: CanvasRenderingContext2D;
@ -37,32 +38,21 @@ export class Canvas extends EventEmitter<CanvasEvents> {
} = {};
lastPlace: number | undefined;
constructor(
config: ClientConfig,
canvas: HTMLCanvasElement,
PanZoom: PanZoom
) {
constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
super();
Canvas.instance = this;
this.config = config;
this.canvas = canvas;
this.PanZoom = PanZoom;
this.ctx = canvas.getContext("2d")!;
canvas.width = config.canvas.size[0];
canvas.height = config.canvas.size[1];
this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
Network.waitFor("canvas").then(([pixels]) => this.handleBatch(pixels));
Network.waitFor("pixelLastPlaced").then(
([time]) => (this.lastPlace = time)
);
Network.on("pixel", this.handlePixel);
this.draw();
}
destroy() {
@ -71,7 +61,26 @@ export class Canvas extends EventEmitter<CanvasEvents> {
this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
Network.off("canvas", this.handleBatch.bind(this));
Network.off("pixel", this.handlePixel);
}
loadConfig(config: ClientConfig) {
this.config = config;
this.canvas.width = config.canvas.size[0];
this.canvas.height = config.canvas.size[1];
Network.waitFor("canvas").then(([pixels]) => {
console.log("loadConfig just received new canvas data");
this.handleBatch(pixels);
this.draw();
});
this.draw();
}
hasConfig() {
return !!this.config;
}
handleMouseDown(e: ClickEvent) {
@ -98,18 +107,23 @@ export class Canvas extends EventEmitter<CanvasEvents> {
this.emit("cursorPos", this.cursor);
}
handleBatch(pixels: string[]) {
pixels.forEach((hex, index) => {
const x = index % this.config.canvas.size[0];
const y = index / this.config.canvas.size[1];
const color = this.Pallete.getColorFromHex(hex);
handleBatch = (pixels: string[]) => {
if (!this.config.canvas) {
throw new Error("handleBatch called with no config");
}
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
};
});
}
for (let x = 0; x < this.config.canvas.size[0]; x++) {
for (let y = 0; y < this.config.canvas.size[1]; y++) {
const hex = pixels[this.config.canvas.size[0] * y + x];
const color = this.Pallete.getColorFromHex(hex);
this.pixels[x + "_" + y] = {
color: color ? color.id : -1,
type: "full",
};
}
}
};
handlePixel = ({ x, y, color }: Pixel) => {
this.pixels[x + "_" + y] = {
@ -162,7 +176,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
this.handlePixel(ack.data);
} else {
// TODO: handle undo pixel
alert("error: " + ack.error);
toast.info(ack.error);
console.warn(
"Attempted to place pixel",
{ x, y, color: this.Pallete.getSelectedColor()!.id },

View File

@ -107,6 +107,15 @@ class Network extends EventEmitter<INetworkEvents> {
});
}
/**
* Track events that we only care about the most recent version of
*
* Used by #waitFor
*
* @param event
* @param args
* @returns
*/
private _emit: typeof this.emit = (event, ...args) => {
this.sentEvents[event] = args;
return this.emit(event, ...args);

View File

@ -0,0 +1,34 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const api = async <T = any>(
endpoint: string,
method: "GET" | "POST" = "GET",
body?: unknown
): Promise<{
status: number;
data: ({ success: true } & T) | { success: false; error: string };
}> => {
const API_HOST = import.meta.env.VITE_API_HOST || "";
const req = await fetch(API_HOST + endpoint, {
method,
credentials: "include",
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
},
body: JSON.stringify(body),
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
data = await req.json();
} catch (e) {
/* empty */
}
return {
status: req.status,
data,
};
};

View File

@ -43,6 +43,8 @@ export interface IAppContext {
loadChat: boolean;
setLoadChat: (v: boolean) => void;
connected: boolean;
hasAdmin: boolean;
}
export interface IPalleteContext {

View File

@ -6,7 +6,7 @@
"start": "node --enable-source-maps dist/index.js",
"build": "tsc",
"lint": "eslint .",
"prisma:studio": "prisma studio",
"prisma:studio": "BROWSER=none prisma studio",
"prisma:migrate": "prisma migrate deploy",
"prisma:seed:palette": "./tool.sh seed_palette",
"tool": "./tool.sh"
@ -16,6 +16,7 @@
"license": "ISC",
"devDependencies": {
"@tsconfig/recommended": "^1.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
"@typescript-eslint/eslint-plugin": "^7.1.0",
@ -30,7 +31,9 @@
"dependencies": {
"@prisma/client": "^5.3.1",
"@sc07-canvas/lib": "^1.0.0",
"body-parser": "^1.20.2",
"connect-redis": "^7.1.1",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-session": "^1.17.3",
"openid-client": "^5.6.5",

View File

@ -7,6 +7,8 @@ Table User {
lastPixelTime DateTime [default: `now()`, not null]
pixelStack Int [not null, default: 0]
undoExpires DateTime
isAdmin Boolean [not null, default: false]
isModerator Boolean [not null, default: false]
pixels Pixel [not null]
FactionMember FactionMember [not null]
}

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isModerator" BOOLEAN NOT NULL DEFAULT false;

View File

@ -20,6 +20,9 @@ model User {
pixelStack Int @default(0) // amount of pixels stacked for this user
undoExpires DateTime? // when the undo for the most recent pixel expires at
isAdmin Boolean @default(false)
isModerator Boolean @default(false)
pixels Pixel[]
FactionMember FactionMember[]
}

View File

@ -0,0 +1,73 @@
import { Router } from "express";
import { User } from "../models/User";
import Canvas from "../lib/Canvas";
const app = Router();
app.use(async (req, res, next) => {
if (!req.session.user) {
res.status(401).json({
success: false,
error: "You are not logged in",
});
return;
}
const user = await User.fromAuthSession(req.session.user);
if (!user) {
res.status(400).json({
success: false,
error: "User data does not exist?",
});
return;
}
if (!user.isAdmin) {
res.status(403).json({
success: false,
error: "user is not admin",
});
return;
}
next();
});
app.get("/check", (req, res) => {
res.send({ success: true });
});
app.get("/canvas/size", async (req, res) => {
const config = Canvas.getCanvasConfig();
res.json({
success: true,
size: {
width: config.size[0],
height: config.size[1],
},
});
});
app.post("/canvas/size", async (req, res) => {
const width = parseInt(req.body.width || "-1");
const height = parseInt(req.body.height || "-1");
if (
isNaN(width) ||
isNaN(height) ||
width < 1 ||
height < 1 ||
width > 10000 ||
height > 10000
) {
res.status(400).json({ success: false, error: "what are you doing" });
return;
}
await Canvas.setSize(width, height);
res.send({ success: true });
});
export default app;

View File

@ -1,15 +1,11 @@
import { Router } from "express";
import { prisma } from "./lib/prisma";
import { OpenID } from "./lib/oidc";
import { prisma } from "../lib/prisma";
import { OpenID } from "../lib/oidc";
import { TokenSet, errors as OIDC_Errors } from "openid-client";
import { Logger } from "./lib/Logger";
import { Logger } from "../lib/Logger";
const app = Router();
app.get("/me", (req, res) => {
res.json(req.session);
});
app.get("/login", (req, res) => {
res.redirect(
OpenID.client.authorizationUrl({

View File

@ -4,15 +4,18 @@ import { Redis } from "./redis";
import { SocketServer } from "./SocketServer";
class Canvas {
private CANVAS_SIZE: [number, number];
/**
* Size of the canvas
*/
private canvasSize: [width: number, height: number];
constructor() {
this.CANVAS_SIZE = [100, 100];
this.canvasSize = [100, 100];
}
getCanvasConfig(): CanvasConfig {
return {
size: this.CANVAS_SIZE,
size: this.canvasSize,
zoom: 7,
pixel: {
cooldown: 10,
@ -25,6 +28,31 @@ class Canvas {
};
}
/**
* Change size of the canvas
*
* Expensive task, will take a bit
*
* @param width
* @param height
*/
async setSize(width: number, height: number) {
this.canvasSize = [width, height];
// we're about to use the redis keys, make sure they are all updated
await this.pixelsToRedis();
// the redis key is 1D, since the dimentions changed we need to update it
await this.canvasToRedis();
// announce the new config, which contains the canvas size
SocketServer.instance.broadcastConfig();
// announce new pixel array that was generated previously
await this.getPixelsArray().then((pixels) => {
SocketServer.instance.io.emit("canvas", pixels);
});
}
/**
* Latest database pixels -> Redis
*/
@ -33,8 +61,8 @@ class Canvas {
const key = Redis.keyRef("pixelColor");
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
for (let x = 0; x < this.canvasSize[0]; x++) {
for (let y = 0; y < this.canvasSize[1]; y++) {
const pixel = await prisma.pixel.findFirst({
where: {
x,
@ -64,8 +92,8 @@ class Canvas {
// (y -> x) because of how the conversion needs to be done later
// if this is inverted, the map will flip when rebuilding the cache (5 minute expiry)
// fixes #24
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
for (let y = 0; y < this.canvasSize[1]; y++) {
for (let x = 0; x < this.canvasSize[0]; x++) {
pixels.push(
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent"
);
@ -87,7 +115,7 @@ class Canvas {
(await redis.get(Redis.key("canvas"))) || ""
).split(",");
pixels[this.CANVAS_SIZE[0] * y + x] =
pixels[this.canvasSize[0] * y + x] =
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent";
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });

View File

@ -3,9 +3,12 @@ import path from "node:path";
import express, { type Express } from "express";
import expressSession from "express-session";
import RedisStore from "connect-redis";
import cors from "cors";
import { Redis } from "./redis";
import APIRoutes from "../api";
import APIRoutes_client from "../api/client";
import APIRoutes_admin from "../api/admin";
import { Logger } from "./Logger";
import bodyParser from "body-parser";
export const session = expressSession({
secret: process.env.SESSION_SECRET,
@ -81,8 +84,19 @@ export class ExpressServer {
});
}
if (process.env.NODE_ENV === "development") {
this.app.use(
cors({
origin: [process.env.CLIENT_ORIGIN!, process.env.ADMIN_ORIGIN!],
credentials: true,
})
);
}
this.app.use(session);
this.app.use("/api", APIRoutes);
this.app.use(bodyParser.json());
this.app.use("/api", APIRoutes_client);
this.app.use("/api/admin", APIRoutes_admin);
this.httpServer.listen(parseInt(process.env.PORT), () => {
Logger.info("Listening on :" + process.env.PORT);

View File

@ -138,6 +138,15 @@ export class SocketServer {
}, 1000);
}
/**
* Broadcast config to all connected clients
*
* Used by canvas size updates
*/
broadcastConfig() {
this.io.emit("config", getClientConfig());
}
async handleConnection(socket: Socket) {
const user =
socket.request.session.user &&

View File

@ -12,6 +12,8 @@ interface IUserData {
lastPixelTime: Date;
pixelStack: number;
undoExpires: Date | null;
isAdmin: boolean;
isModerator: boolean;
}
export class User {
@ -23,6 +25,9 @@ export class User {
authSession?: AuthSession;
undoExpires?: Date;
isAdmin: boolean;
isModerator: boolean;
sockets: Set<Socket<ClientToServerEvents, ServerToClientEvents>> = new Set();
private _updatedAt: number;
@ -35,6 +40,9 @@ export class User {
this.pixelStack = data.pixelStack;
this.undoExpires = data.undoExpires || undefined;
this.isAdmin = data.isAdmin;
this.isModerator = data.isModerator;
this._updatedAt = Date.now();
}