Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • Ategon/canvas
  • marius851000/canvas
  • sc07/canvas
3 results
Show changes
Showing
with 1186 additions and 120 deletions
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>
);
};