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:
parent
8559aea7c3
commit
ad1a785451
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -43,6 +43,8 @@ export interface IAppContext {
|
|||
loadChat: boolean;
|
||||
setLoadChat: (v: boolean) => void;
|
||||
connected: boolean;
|
||||
|
||||
hasAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface IPalleteContext {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isModerator" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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({
|
|
@ -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 });
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue