Skip to content
import { toast } from "react-toastify";
// 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,
};
};
export const handleError = (
...props: [
status: number,
data: { success: true } | { success: false; error: string },
]
) => {
console.error(...props);
if (typeof props[0] === "number") {
const [status, data] = props;
toast.error(
`${status} ${"error" in data ? data.error : JSON.stringify(data)}`
);
}
};
......@@ -7,6 +7,9 @@ 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";
import { AuditLog } from "./pages/AuditLog/auditlog.tsx";
import { ToastWrapper } from "./components/ToastWrapper.tsx";
const router = createBrowserRouter(
[
......@@ -22,11 +25,19 @@ const router = createBrowserRouter(
path: "/accounts",
element: <AccountsPage />,
},
{
path: "/service/settings",
element: <ServiceSettingsPage />,
},
{
path: "/audit",
element: <AuditLog />,
},
],
},
],
{
basename: import.meta.env.VITE_APP_ROOT,
basename: __APP_ROOT__,
}
);
......@@ -35,6 +46,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<NextUIProvider>
<ThemeProvider defaultTheme="system">
<RouterProvider router={router} />
<ToastWrapper />
</ThemeProvider>
</NextUIProvider>
</React.StrictMode>
......
import { useEffect, useState } from "react";
import { api, handleError } from "../../lib/utils";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@nextui-org/react";
type AuditLogAction = "BAN_CREATE" | "BAN_UPDATE" | "BAN_DELETE";
type AuditLog = {
id: number;
userId: string;
action: AuditLogAction;
reason?: string;
comment?: string;
banId?: number;
createdAt: string;
updatedAt?: string;
};
export const AuditLog = () => {
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
useEffect(() => {
api<{ auditLogs: AuditLog[] }>("/api/admin/audit", "GET").then(
({ status, data }) => {
if (status === 200) {
if (data.success) {
setAuditLogs(data.auditLogs);
} else {
handleError(status, data);
}
} else {
handleError(status, data);
}
}
);
}, []);
return (
<>
<h4 className="text-l font-semibold">Audit Log</h4>
<div className="relative">
<Table>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>User ID</TableColumn>
<TableColumn>Action</TableColumn>
<TableColumn>Reason</TableColumn>
<TableColumn>Comment</TableColumn>
<TableColumn>Created At / Updated At</TableColumn>
</TableHeader>
<TableBody>
{auditLogs.map((log) => (
<TableRow key={log.id}>
<TableCell>{log.id}</TableCell>
<TableCell>{log.userId}</TableCell>
<TableCell>{log.action}</TableCell>
<TableCell>{log.reason}</TableCell>
<TableCell>{log.comment}</TableCell>
<TableCell>
{log.createdAt} / {log.updatedAt}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
);
};
import { BreadcrumbItem, Breadcrumbs, Button, Input } from "@nextui-org/react";
import { useEffect, useState } from "react";
import { api, handleError } from "../../lib/utils";
import { LoadingOverlay } from "../../components/LoadingOverlay";
import { toast } from "react-toastify";
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 {
handleError(status, data);
}
} else {
handleError(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) {
toast.success("Canvas size has been changed");
} else {
handleError(status, data);
}
} else {
handleError(status, data);
}
})
.finally(() => {
setLoading(false);
});
};
return (
<>
<h4 className="text-l font-semibold">Canvas</h4>
<div className="relative">
{loading && <LoadingOverlay />}
<b>
Canvas size is resource intensive, this will take a minute to complete
</b>
<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>
</>
);
};
/// <reference types="vite/client" />
declare const __APP_ROOT__: string;
......@@ -15,4 +15,7 @@ export default defineConfig({
include: "**/*.{jsx,tsx}",
}),
],
define: {
__APP_ROOT__: JSON.stringify(process.env.APP_ROOT),
},
});
{
"extends": ["react-app"],
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrors": "all",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
]
},
"overrides": [
{
"files": ["**/*.ts?(x)"],
"rules": {}
}
]
}
# where the backend is located
VITE_API_HOST=http://localhost:3000
# what homeserver hosts the matrix users
VITE_MATRIX_HOST=aftermath.gg
# what hostname does the element instance run on
VITE_ELEMENT_HOST=https://chat.fediverse.events
\ No newline at end of file
......@@ -7,47 +7,31 @@
"build": "vite build",
"dev": "vite serve",
"preview": "vite preview",
"lint": "eslint ."
"lint": "eslint --format gitlab ."
},
"type": "module",
"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",
"@nextui-org/react": "^2.2.9",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@icons-pack/react-simple-icons": "^10.2.0",
"@nextui-org/react": "^2.6.11",
"@sc07-canvas/lib": "^1.0.0",
"@typescript-eslint/parser": "^7.1.0",
"@theme-toggles/react": "^4.1.0",
"eventemitter3": "^5.0.1",
"framer-motion": "^11.0.5",
"framer-motion": "^11.3.2",
"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"
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@tsconfig/vite-react": "^3.0.0",
"@types/grecaptcha": "^3.0.9",
"@types/lodash.throttle": "^4.1.9",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/socket.io-client": "^3.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.35",
"sass": "^1.70.0",
"tailwindcss": "^3.4.1",
"vite": "^5.1.1",
"vite-plugin-simple-html": "^0.1.2"
"sass": "^1.83.0"
}
}
......@@ -23,3 +23,13 @@
pointer-events: none;
touch-action: none;
}
.board-overlay {
position: absolute;
top: 0;
left: 0;
}
.no-interact {
pointer-events: none;
}
import { Header } from "./Header";
import { AppContext } from "../contexts/AppContext";
import { Header } from "./Header/Header";
import { AppContext, useAppContext } from "../contexts/AppContext";
import { CanvasWrapper } from "./CanvasWrapper";
import { Pallete } from "./Pallete";
import { TemplateContext } from "../contexts/TemplateContext";
import { SettingsSidebar } from "./Settings/SettingsSidebar";
import { DebugModal } from "./Debug/DebugModal";
import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper";
import { useEffect } from "react";
import { ChatContext } from "../contexts/ChatContext";
import "react-toastify/dist/ReactToastify.css";
import { AuthErrors } from "./AuthErrors";
import "../lib/keybinds";
import { PixelWhoisSidebar } from "./PixelWhoisSidebar";
import { KeybindModal } from "./KeybindModal";
import { ProfileModal } from "./Profile/ProfileModal";
import { WelcomeModal } from "./Welcome/WelcomeModal";
import { InfoSidebar } from "./Info/InfoSidebar";
import { ModModal } from "./Moderation/ModModal";
import { DynamicModals } from "./DynamicModals";
import { ToastWrapper } from "./ToastWrapper";
// const Chat = lazy(() => import("./Chat/Chat"));
console.log("Client init with version " + __COMMIT_HASH__);
// const DynamicallyLoadChat = () => {
// const { loadChat } = useAppContext();
// return <React.Suspense>{loadChat && <Chat />}</React.Suspense>;
// };
// get access to context data
const AppInner = () => {
const { config } = useAppContext();
useEffect(() => {
// detect auth callback for chat, regardless of it being loaded
// callback token expires quickly, so we should exchange it as quick as possible
(async () => {
const params = new URLSearchParams(window.location.search);
if (params.has("loginToken")) {
if (!config) {
console.warn(
"[App] loginToken parsing is delayed because config is not available"
);
return;
}
// login button opens a new tab that redirects here
// if we're that tab, we should try to close this tab when we're done
// should work because this tab is opened by JS
const shouldCloseWindow =
window.location.pathname.startsWith("/chat_callback");
// token provided by matrix's /sso/redirect
const token = params.get("loginToken")!;
// immediately remove from url to prevent reloading
window.history.replaceState({}, "", "/");
const loginReq = await fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/login`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "m.login.token",
token,
}),
}
);
const loginRes = await loginReq.json();
console.log("[Chat] Matrix login", loginReq.status);
switch (loginReq.status) {
case 200: {
// success
console.log("[Chat] Logged in successfully", loginRes);
localStorage.setItem(
"matrix.access_token",
loginRes.access_token + ""
);
localStorage.setItem("matrix.device_id", loginRes.device_id + "");
localStorage.setItem("matrix.user_id", loginRes.user_id + "");
if (shouldCloseWindow) {
console.log(
"[Chat] Path matches autoclose, attempting to close window..."
);
window.close();
alert("You can close this window and return to the other tab :)");
} else {
console.log(
"[Chat] Path doesn't match autoclose, not doing anything"
);
}
break;
}
case 400:
case 403:
console.log("[Chat] Matrix login", loginRes);
alert(
"[Chat] Failed to login\n" +
loginRes.errcode +
" " +
loginRes.error
);
break;
case 429:
alert(
"[Chat] Failed to login, ratelimited.\nTry again in " +
Math.floor(loginRes.retry_after_ms / 1000) +
"s\n" +
loginRes.errcode +
" " +
loginRes.error
);
break;
default:
alert(
"Error " +
loginReq.status +
" returned when trying to login to chat"
);
}
}
})();
}, [config]);
return (
<>
<Header />
{config && <CanvasWrapper />}
<ToolbarWrapper />
{/* <DynamicallyLoadChat /> */}
<DebugModal />
<SettingsSidebar />
<InfoSidebar />
<PixelWhoisSidebar />
<KeybindModal />
<AuthErrors />
<ProfileModal />
<WelcomeModal />
<ModModal />
<ToastWrapper />
<DynamicModals />
</>
);
};
const App = () => {
return (
<AppContext>
<Header />
<CanvasWrapper />
<Pallete />
<ChatContext>
<TemplateContext>
<AppInner />
</TemplateContext>
</ChatContext>
</AppContext>
);
};
......
import {
Button,
Link,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
import { useEffect, useState } from "react";
const Params = {
TYPE: "auth_type",
ERROR: "auth_error",
ERROR_DESC: "auth_error_desc",
CAN_RETRY: "auth_retry",
};
/**
* Show popups that detail auth error messages
* @returns
*/
export const AuthErrors = () => {
const [params, setParams] = useState(
new URLSearchParams(window.location.search)
);
const onClose = () => {
const url = new URL(window.location.href);
url.search = "";
window.history.replaceState({}, "", url.toString());
setParams(new URLSearchParams(window.location.search));
};
return (
<>
<OPError
isOpen={params.get(Params.TYPE) === "op"}
onClose={onClose}
params={params}
/>
<BannedError
isOpen={params.get(Params.TYPE) === "banned"}
onClose={onClose}
params={params}
/>
</>
);
};
const BannedError = ({
isOpen,
onClose,
params,
}: {
isOpen: boolean;
onClose: () => void;
params: URLSearchParams;
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} isDismissable={false}>
<ModalContent>
{(_onClose) => (
<>
<ModalHeader>Login Error</ModalHeader>
<ModalBody>
<b>Your instance is banned.</b> You cannot proceed.
<br />
<br />
{params.has(Params.ERROR_DESC) ? (
<>Reason: {params.get(Params.ERROR_DESC)}</>
) : (
<>No reason provided</>
)}
</ModalBody>
</>
)}
</ModalContent>
</Modal>
);
};
/**
* This is for OP errors, these might not be retryable
* @param param0
* @returns
*/
const OPError = ({
isOpen,
onClose,
params,
}: {
isOpen: boolean;
onClose: () => void;
params: URLSearchParams;
}) => {
const canRetry = params.has(Params.CAN_RETRY);
const [error, _setError] = useState(params.get(Params.ERROR));
const [errorDesc, setErrorDesc] = useState(params.get(Params.ERROR_DESC));
useEffect(() => {
switch (params.get(Params.ERROR)) {
case "invalid_grant":
setErrorDesc("Invalid token, try logging in again");
break;
}
}, [params]);
return (
<Modal isOpen={isOpen} onClose={onClose} isDismissable={false}>
<ModalContent>
{(_onClose) => (
<>
<ModalHeader>Login Error</ModalHeader>
<ModalBody>
<b>Error:</b> {error}
<br />
<br />
<b>Error Description:</b> {errorDesc}
</ModalBody>
<ModalFooter>
{canRetry && (
<Button color="primary" href="/api/login" as={Link}>
Login
</Button>
)}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};
import { createRef, useContext, useEffect } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Canvas } from "../lib/canvas";
import { useAppContext } from "../contexts/AppContext";
import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer";
import { RendererContext } from "@sc07-canvas/lib/src/renderer/RendererContext";
import { ViewportMoveEvent } from "@sc07-canvas/lib/src/renderer/PanZoom";
import throttle from "lodash.throttle";
import { Routes } from "../lib/routes";
import { ICanvasPosition, IPosition } from "@sc07-canvas/lib/src/net";
import { IPosition } from "@sc07-canvas/lib/src/net";
import { Template } from "./Templating/Template";
import { Template as TemplateCl } from "../lib/template";
import { IRouterData, Router } from "../lib/router";
import { KeybindManager } from "../lib/keybinds";
import { BlankOverlay } from "./Overlay/BlankOverlay";
import { HeatmapOverlay } from "./Overlay/HeatmapOverlay";
import { useTemplateContext } from "../contexts/TemplateContext";
import { PixelPulses } from "./Overlay/PixelPulses";
import { CanvasUtils } from "../lib/canvas.utils";
export const CanvasWrapper = () => {
// to prevent safari from blurring things, use the zoom css property
const { config } = useAppContext();
const getInitialPosition = useCallback<
(useCssZoom: boolean) =>
| {
x: number;
y: number;
zoom?: number;
}
| undefined
>(
(useCssZoom) => {
const router = Router.get().canvas;
if (!router) return undefined;
if (!config) {
console.warn("getInitialPosition called with no config");
return undefined;
}
const { transformX, transformY } = CanvasUtils.canvasToPanZoomTransform(
router.x,
router.y,
config.canvas.size,
useCssZoom
);
return {
x: transformX,
y: transformY,
zoom: router.zoom,
};
},
[config]
);
return (
<main>
<PanZoomWrapper>
<PanZoomWrapper initialPosition={getInitialPosition}>
<BlankOverlay />
<HeatmapOverlay />
<PixelPulses />
{config && <Template />}
<CanvasInner />
<Cursor />
</PanZoomWrapper>
</main>
);
};
const parseHashParams = (canvas: Canvas) => {
// maybe move this to a utility inside routes.ts
let { hash } = new URL(window.location.href);
if (hash.indexOf("#") === 0) {
hash = hash.slice(1);
}
let params = new URLSearchParams(hash);
let position: {
x?: number;
y?: number;
zoom?: number;
} = {};
if (params.has("x") && !isNaN(parseInt(params.get("x")!)))
position.x = parseInt(params.get("x")!);
if (params.has("y") && !isNaN(parseInt(params.get("y")!)))
position.y = parseInt(params.get("y")!);
if (params.has("zoom") && !isNaN(parseInt(params.get("zoom")!)))
position.zoom = parseInt(params.get("zoom")!);
if (
typeof position.x === "number" &&
typeof position.y === "number" &&
typeof position.zoom === "number"
) {
const { transformX, transformY } = canvas.canvasToPanZoomTransform(
position.x,
position.y
);
return {
x: transformX,
y: transformY,
zoom: position.zoom,
};
}
const Cursor = () => {
const { cursor } = useAppContext();
const [color, setColor] = useState<string>();
useEffect(() => {
if (typeof cursor.color === "number") {
const color = Canvas.instance?.Pallete.getColor(cursor.color);
setColor(color?.hex);
} else {
setColor(undefined);
}
}, [setColor, cursor.color]);
if (!color) return <></>;
return (
<div
className="noselect"
style={{
position: "absolute",
top: cursor.y,
left: cursor.x,
backgroundColor: "#" + color,
width: "1px",
height: "1px",
opacity: 0.5,
}}
></div>
);
};
const CanvasInner = () => {
const canvasRef = createRef<HTMLCanvasElement>();
const { config, setCanvasPosition, setCursorPosition } = useAppContext();
const canvasRef = useRef<HTMLCanvasElement | null>();
const canvas = useRef<Canvas>();
const { config, setCanvasPosition, setCursor, setPixelWhois } =
useAppContext();
const {
x: templateX,
y: templateY,
enable: templateEnable,
} = useTemplateContext();
const PanZoom = useContext(RendererContext);
useEffect(() => {
if (!config.canvas || !canvasRef.current) return;
const canvas = canvasRef.current!;
const canvasInstance = new Canvas(config, canvas, PanZoom);
/**
* Is the canvas coordinate within the bounds of the canvas?
*/
const isCoordInCanvas = useCallback(
(x: number, y: number): boolean => {
if (!canvas.current) {
console.warn(
"[CanvasWrapper#isCoordInCanvas] canvas instance does not exist"
);
return false;
}
if (x < 0 || y < 0) return false; // not positive, impossible to be on canvas
// canvas size can dynamically change, so we need to check the current config
// we're depending on canvas.instance's config so we don't have to use a react dependency
if (canvas.current.hasConfig()) {
const {
canvas: {
size: [width, height],
},
} = canvas.current.getConfig();
{
// TODO: handle hash changes and move viewport
// NOTE: this will need to be cancelled if handleViewportMove was executed recently
if (x >= width || y >= height) return false; // out of bounds
} else {
// although this should never happen, log it
console.warn(
"[CanvasWrapper#isCoordInCanvas] canvas config is not available yet"
);
}
const position = parseHashParams(canvasInstance);
if (position) {
PanZoom.setPosition(position, { suppressEmit: true });
return true;
},
[canvas.current]
);
const handlePixelWhois = useCallback(
({ clientX, clientY }: { clientX: number; clientY: number }) => {
if (!canvas.current) {
console.warn(
"[CanvasWrapper#handlePixelWhois] canvas instance does not exist"
);
return;
}
}
const handleViewportMove = throttle((state: ViewportMoveEvent) => {
const pos = canvasInstance.panZoomTransformToCanvas();
const [x, y] = canvas.current.screenToPos(clientX, clientY);
if (!isCoordInCanvas(x, y)) return; // out of bounds
const canvasPosition: ICanvasPosition = {
x: pos.canvasX,
y: pos.canvasY,
zoom: state.scale >> 0,
};
// .......
// .......
// .......
// ...x...
// .......
// .......
// .......
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
setPixelWhois({ x, y, surrounding });
},
[canvas.current]
);
const getTemplatePixel = useCallback(
(x: number, y: number) => {
if (!templateEnable) return;
if (x < templateX || y < templateY) return;
x -= templateX;
y -= templateY;
setCanvasPosition(canvasPosition);
return TemplateCl.instance.getPixel(x, y);
},
[templateX, templateY]
);
const handlePickPixel = useCallback(
({ clientX, clientY }: { clientX: number; clientY: number }) => {
if (!canvas.current) {
console.warn(
"[CanvasWrapper#handlePickPixel] canvas instance does not exist"
);
return;
}
const [x, y] = canvas.current.screenToPos(clientX, clientY);
if (!isCoordInCanvas(x, y)) return; // out of bounds
let pixelColor = -1;
window.location.replace(Routes.canvas(canvasPosition));
}, 1000);
const templatePixel = getTemplatePixel(x, y);
if (templatePixel) {
pixelColor =
canvas.current.Pallete.getColorFromHex(templatePixel.slice(1))?.id ||
-1;
}
if (pixelColor === -1) {
pixelColor = canvas.current.getPixel(x, y)?.color || -1;
}
if (pixelColor === -1) {
return;
}
// no need to use canvas#setCursor as Palette.tsx already does that
setCursor((v) => ({
...v,
color: pixelColor,
}));
},
[canvas.current]
);
useEffect(() => {
if (!canvasRef.current) return;
canvas.current = new Canvas(canvasRef.current!, PanZoom);
canvas.current.on("canvasReady", () => {
console.log("[CanvasWrapper] received canvasReady");
});
KeybindManager.on("PIXEL_WHOIS", handlePixelWhois);
KeybindManager.on("PICK_COLOR", handlePickPixel);
return () => {
KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
KeybindManager.off("PICK_COLOR", handlePickPixel);
canvas.current!.destroy();
};
}, [PanZoom]);
useEffect(() => {
Router.PanZoom = PanZoom;
}, [PanZoom]);
useEffect(() => {
if (!canvas.current) {
console.warn("canvas isntance doesn't exist");
return;
}
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();
setCursor((v) => ({
...v,
x: undefined,
y: undefined,
}));
} else {
// fixes not passing the current value
setCursorPosition({ ...pos });
setCursor((v) => ({
...v,
x: pos.x,
y: pos.y,
}));
}
}, 1);
canvas.current.on("cursorPos", handleCursorPos);
return () => {
canvas.current!.off("cursorPos", handleCursorPos);
};
}, [config, setCursor]);
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);
}, [config]);
const handleNavigate = useCallback(
(data: IRouterData) => {
if (data.canvas) {
const position = canvas.current!.canvasToPanZoomTransform(
data.canvas.x,
data.canvas.y
);
PanZoom.setPosition(
{
x: position.transformX,
y: position.transformY,
zoom: data.canvas.zoom || 0, // TODO: fit canvas to viewport instead of defaulting
},
{ 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 position from Router is setup in <CanvasWrapper>
const handleViewportMove = (state: ViewportMoveEvent) => {
if (Date.now() - initAt < 60 * 1000) {
console.debug(
"[CanvasWrapper] handleViewportMove called soon after init",
Date.now() - initAt
);
}
if (canvas.current) {
const pos = canvas.current?.panZoomTransformToCanvas();
setCanvasPosition({
x: pos.canvasX,
y: pos.canvasY,
zoom: state.scale >> 0,
});
} else {
console.warn(
"[CanvasWrapper] handleViewportMove has no canvas instance"
);
}
Router.queueUpdate();
};
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]);
return (
<canvas
......@@ -125,7 +366,7 @@ const CanvasInner = () => {
width="1000"
height="1000"
className="pixelate"
ref={canvasRef}
ref={(ref) => (canvasRef.current = ref)}
></canvas>
);
};
import { useRef } from "react";
const Chat = () => {
const ref = useRef<HTMLDivElement | null>(null);
return (
<div ref={ref} style={{ position: "fixed", top: 0, left: 0, zIndex: 999 }}>
chat
</div>
);
};
export default Chat;
import { Button } from "@nextui-org/react";
import { useChatContext } from "../../contexts/ChatContext";
import { useAppContext } from "../../contexts/AppContext";
const InnerChatSettings = () => {
const { user: authUser } = useAppContext();
const { user, doLogin, doLogout } = useChatContext();
if (!authUser) {
return <>You must be logged in first</>;
}
return (
<>
{!user && <Button onClick={doLogin}>Login</Button>}
{user && (
<>
<div className="flex gap-1">
<div className="flex-grow">{user.userId}</div>
<Button onClick={doLogout}>Logout</Button>
</div>
</>
)}
</>
);
};
export default InnerChatSettings;
import { Badge, Button } from "@nextui-org/react";
import { useChatContext } from "../../contexts/ChatContext";
import { useAppContext } from "../../contexts/AppContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faComments } from "@fortawesome/free-solid-svg-icons";
const OpenChatButton = () => {
const { config } = useAppContext();
const { notificationCount, doLogin } = useChatContext();
return (
<Badge
content={notificationCount}
isInvisible={notificationCount === 0}
color="danger"
size="sm"
>
{config?.chat?.element_host && (
<Button onPress={doLogin} variant="faded">
<FontAwesomeIcon icon={faComments} />
<p>Chat</p>
</Button>
)}
</Badge>
);
};
export default OpenChatButton;
import { useEffect } from "react";
import { Debug, FlagCategory } from "@sc07-canvas/lib/src/debug";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Switch,
useDisclosure,
} from "@nextui-org/react";
export const DebugModal = () => {
const { isOpen, onOpen, onOpenChange } = useDisclosure();
useEffect(() => {
const handleOpen = () => {
onOpen();
};
Debug.on("openTools", handleOpen);
return () => {
Debug.off("openTools", handleOpen);
};
}, []);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="center">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Debug Tools
</ModalHeader>
<ModalBody>
<Button onPress={() => Debug.openDebug()}>
Open Debug Information
</Button>
{Debug.flags.getAll().map((flag, i, arr) => (
<>
{arr[i - 1]?.category !== flag.category && (
<p>{FlagCategory[flag.category]}</p>
)}
<div key={flag.id}>
<Switch
size="sm"
defaultSelected={flag.enabled}
onValueChange={(v) => Debug.flags.setEnabled(flag.id, v)}
>
{flag.id}
</Switch>
</div>
</>
))}
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};
import { useCallback, useEffect, useState } from "react";
import { DynamicModal, IDynamicModal } from "../lib/alerts";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
interface IModal {
id: number;
open: boolean;
modal: IDynamicModal;
}
/**
* React base to hold dynamic modals
*
* Dynamic modals are created via lib/alerts.tsx
*
* @returns
*/
export const DynamicModals = () => {
const [modals, setModals] = useState<IModal[]>([]);
const handleShowModal = useCallback(
(modal: IDynamicModal) => {
setModals((modals) => [
...modals,
{
id: Math.floor(Math.random() * 9999),
open: true,
modal,
},
]);
},
[setModals]
);
const handleHideModal = useCallback(
(modalId: number) => {
setModals((modals_) => {
const modals = [...modals_];
if (modals.find((m) => m.id === modalId)) {
modals.find((m) => m.id === modalId)!.open = false;
}
return modals;
});
setTimeout(() => {
setModals((modals_) => {
const modals = [...modals_];
if (modals.find((m) => m.id === modalId)) {
modals.splice(
modals.indexOf(modals.find((m) => m.id === modalId)!),
1
);
}
return modals;
});
}, 1000);
},
[setModals]
);
useEffect(() => {
DynamicModal.on("showModal", handleShowModal);
return () => {
DynamicModal.off("showModal", handleShowModal);
};
}, []);
return (
<>
{modals.map(({ id, open, modal }) => (
<Modal key={id} isOpen={open} onClose={() => handleHideModal(id)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{modal.title}</ModalHeader>
<ModalBody>{modal.body}</ModalBody>
<ModalFooter>
<Button onClick={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
))}
</>
);
};
import { useAppContext } from "../contexts/AppContext";
import { Button, Link } from "@nextui-org/react";
/**
* *oh god the terrible code*
*
* not sure of another clean way to do this
*
* This is used to show details about the event, immediately on page load
*
* used by the canvas preview page to get people hyped up for the event (<7 days before)
*/
export const EventInfoOverlay = () => {
const { setInfoSidebar, setSettingsSidebar } = useAppContext();
return (
<div
className="bg-black text-white p-4 fixed top-0 left-0 w-full z-[9999] flex flex-row"
style={{
pointerEvents: "initial",
}}
>
<div>
<h1 className="text-4xl font-bold">Canvas 2024</h1>
<h2 className="text-3xl">Ended July 16th @ 4am UTC</h2>
</div>
<div className="flex-grow" />
<div>
<Button as={Link} href="https://sc07.shop" color="primary">
Shop (poster prints!)
</Button>
<Button onPress={() => setInfoSidebar(true)}>Info</Button>
<Button onPress={() => setSettingsSidebar(true)}>Settings</Button>
</div>
</div>
);
};
import { User } from "./Header/User";
export const Header = () => {
return (
<header id="main-header">
<div></div>
<div className="spacer"></div>
<div className="box">
<User />
</div>
</header>
);
};