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 1420 additions and 34 deletions
import { faMessage, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Link, Spinner, User } from "@nextui-org/react";
import { ClientConfig } from "@sc07-canvas/lib/src/net";
import { MouseEvent, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { useAppContext } from "../../contexts/AppContext";
export interface IUser {
sub: string;
display_name?: string;
picture_url?: string;
profile_url?: string;
isAdmin: boolean;
isModerator: boolean;
}
const getMatrixLink = (user: IUser, config: ClientConfig) => {
return `${config.chat.element_host}/#/user/@${user.sub.replace("@", "=40")}:${config.chat.matrix_homeserver}`;
};
/**
* Small UserCard that shows profile picture, display name, full username, message box and (currently unused) view profile button
* @param param0
* @returns
*/
export const UserCard = ({ user }: { user: IUser }) => {
const { config, setProfile } = useAppContext();
const [messageStatus, setMessageStatus] = useState<
"loading" | "no_account" | "has_account" | "error"
>("loading");
useEffect(() => {
if (!config) {
console.warn("[UserCard] config is not available yet");
return;
}
setMessageStatus("loading");
fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/profile/${encodeURIComponent(`@${user.sub.replace("@", "=40")}:${config.chat.matrix_homeserver}`)}`
)
.then((req) => {
if (req.status === 200) {
setMessageStatus("has_account");
} else {
setMessageStatus("no_account");
}
})
.catch((e) => {
console.error(
"Error while getting Matrix account details for " + user.sub,
e
);
setMessageStatus("error");
toast.error(
"Error while getting Matrix account details for " + user.sub
);
});
}, [user, config]);
const handleMatrixClick = (e: MouseEvent) => {
if (messageStatus === "no_account") {
e.preventDefault();
toast.info("This user has not setup chat yet, you cannot message them");
}
};
const openProfile = () => {
setProfile(user.sub);
};
const name = useMemo(() => {
if (!user || !user.sub) {
return "Unknown";
}
const regex = /^(.*)@/;
const match = user.sub.match(regex);
if (match) {
return match[1];
}
return "Unknown";
}, [user]);
return (
<div className="flex flex-col gap-1">
<div className="flex flex-row space-between p-2">
<User
name={user?.display_name || name}
description={user?.sub || "Unknown"}
avatarProps={{
showFallback: true,
name: undefined,
src: user?.picture_url,
}}
/>
<div className="ml-auto">
{config && (
<Button
isIconOnly
as={Link}
href={getMatrixLink(user, config)}
target="_blank"
onClick={handleMatrixClick}
>
{messageStatus === "loading" ? (
<Spinner />
) : (
<FontAwesomeIcon
icon={messageStatus === "error" ? faWarning : faMessage}
color="inherit"
/>
)}
</Button>
)}
</div>
</div>
<Button size="sm" onPress={openProfile}>
View Profile
</Button>
</div>
);
};
import { Switch } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import React, { lazy } from "react";
const InnerChatSettings = lazy(() => import("../Chat/InnerChatSettings"));
export const ChatSettings = () => {
const { loadChat, setLoadChat } = useAppContext();
return (
<div className="flex flex-col p-2">
<header className="flex flex-col gap-2">
<div className="flex items-center">
<Switch
size="sm"
isSelected={loadChat || false}
onValueChange={setLoadChat}
/>
<h2 className="text-xl">Chat</h2>
</div>
<p className="text-default-600 text-xs">Chatting with other canvas users</p>
</header>
<section>
<React.Suspense>{loadChat &&
<div className="mt-4">
<InnerChatSettings />
</div>
}</React.Suspense>
</section>
</div>
);
};
import { faGear } from "@fortawesome/free-solid-svg-icons";
import { useAppContext } from "../../contexts/AppContext";
import { SidebarBase } from "../SidebarBase";
import { Button, Divider } from "@nextui-org/react";
import { TemplateSettings } from "./TemplateSettings";
import { ChatSettings } from "./ChatSettings";
import { OverlaySettings } from "../Overlay/OverlaySettings";
export const SettingsSidebar = () => {
const { settingsSidebar, setSettingsSidebar, setShowKeybinds } = useAppContext();
return (
<SidebarBase shown={settingsSidebar} setSidebarShown={setSettingsSidebar} icon={faGear} title="Settings" description="Configuration options for customizing your experience" side="Right">
<div className="p-4 flex flex-col gap-4">
<TemplateSettings />
<Divider />
<ChatSettings />
<Divider />
<OverlaySettings />
<Divider />
<section>
<Button
onPress={() => {
setShowKeybinds(true);
setSettingsSidebar(false);
}}
>
Keybinds
</Button>
</section>
</div>
</SidebarBase>
)
};
\ No newline at end of file
import { useTemplateContext } from "../../contexts/TemplateContext";
import { Input, Select, SelectItem, Slider, Switch } from "@nextui-org/react";
export const TemplateSettings = () => {
const {
enable,
setEnable,
url,
setURL,
width,
setWidth,
x,
setX,
y,
setY,
opacity,
setOpacity,
style,
setStyle,
showMobileTools,
setShowMobileTools,
} = useTemplateContext();
return (
<div className="flex flex-col p-2">
<header className="flex flex-col gap-2">
<div className="flex items-center">
<Switch
size="sm"
isSelected={enable || false}
onValueChange={setEnable}
/>
<h2 className="text-xl">Template</h2>
</div>
<p className="text-default-600 text-xs">Displaying an image over the canvas to help guide placing</p>
</header>
{enable && <section className="flex flex-col gap-2 mt-4">
<Input
label="Template URL"
size="sm"
value={url || ""}
onValueChange={setURL}
/>
<Input
label="Template Width"
size="sm"
type="number"
min="1"
max={10_000}
value={width?.toString() || ""}
onValueChange={(v) => setWidth(parseInt(v))}
/>
<div className="flex flex-row gap-1">
<Input
label="Template X"
size="sm"
type="number"
value={x?.toString() || ""}
onValueChange={(v) => setX(parseInt(v))}
/>
<Input
label="Template Y"
size="sm"
type="number"
value={y?.toString() || ""}
onValueChange={(v) => setY(parseInt(v))}
/>
</div>
<Slider
label="Template Opacity"
step={1}
minValue={0}
maxValue={100}
value={opacity ?? 100}
onChange={(v) => setOpacity(v as number)}
getValue={(v) => v + "%"}
/>
<Select
label="Template Style"
size="sm"
selectedKeys={[style]}
onChange={(e) => setStyle(e.target.value as any)}
>
<SelectItem key="SOURCE">Source</SelectItem>
<SelectItem key="ONE_TO_ONE">One-to-one</SelectItem>
<SelectItem key="ONE_TO_ONE_INCORRECT">
One-to-one (keep incorrect)
</SelectItem>
<SelectItem key="DOTTED_SMALL">Dotted Small</SelectItem>
<SelectItem key="DOTTED_BIG">Dotted Big</SelectItem>
<SelectItem key="SYMBOLS">Symbols</SelectItem>
<SelectItem key="NUMBERS">Numbers</SelectItem>
</Select>
{style !== "ONE_TO_ONE" && (
<div>
<b>Warning:</b> Template color picking only
<br />
works with one-to-one template style
</div>
)}
<Switch
className="md:hidden"
isSelected={showMobileTools}
onValueChange={setShowMobileTools}
>
Show Mobile Tools
</Switch>
</section>}
</div>
);
};
import { motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { Button, Divider } from "@nextui-org/react";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { JSX } from "react";
/**
* Information sidebar
*
* TODO: add customization for this post-event (#46)
*
* @returns
*/
export const SidebarBase = ({
children,
shown,
icon,
setSidebarShown,
title,
description,
side,
}: {
children: string | JSX.Element | JSX.Element[];
icon: IconProp;
shown: boolean;
setSidebarShown: (value: boolean) => void;
title: string;
description: string;
side: "Left" | "Right";
}) => {
return (
<div>
<motion.div
className={`absolute w-screen h-screen z-50 left-0 top-0 bg-black pointer-events-none`}
initial={{ opacity: 0, visibility: "hidden" }}
animate={{
opacity: shown ? 0.25 : 0,
visibility: shown ? "visible" : "hidden",
}}
transition={{ type: "spring", stiffness: 50 }}
/>
<motion.div
className={`min-w-[20rem] max-w-[75vw] md:max-w-[30vw] bg-white dark:bg-black flex flex-col justify-between fixed ${side === "Left" ? "left-0" : "right-0"} h-full shadow-xl overflow-y-auto z-50 top-0`}
initial={{ x: side === "Left" ? "-150%" : "150%" }}
animate={{
x: shown
? side === "Left"
? "-50%"
: "50%"
: side === "Left"
? "-150%"
: "150%",
}}
transition={{ type: "spring", stiffness: 50 }}
/>
<motion.div
className={`min-w-[20rem] max-w-[75vw] md:max-w-[30vw] bg-white dark:bg-black text-black dark:text-white flex flex-col fixed ${side === "Left" ? "left-0" : "right-0"} h-full shadow-xl overflow-y-auto z-50 top-0`}
initial={{ x: side === "Left" ? "-100%" : "100%" }}
animate={{ x: shown ? 0 : side === "Left" ? "-100%" : "100%" }}
transition={{ type: "spring", stiffness: 50 }}
>
<header className="flex p-4 justify-between items-center">
<div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={icon} size="lg" />
<div>
<h1 className="text-xl">{title}</h1>
<p className="text-xs text-default-600">{description}</p>
</div>
</div>
</div>
<Button
size="sm"
isIconOnly
onClick={() => setSidebarShown(false)}
variant="solid"
className="ml-4"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
</header>
<Divider />
{children}
</motion.div>
</div>
);
};
import { Switch } from "@nextui-org/react";
import { useTemplateContext } from "../../contexts/TemplateContext";
export const MobileTemplateButtons = () => {
const { enable, setEnable, url } = useTemplateContext();
return (
<div className="md:hidden toolbar-box top-[-10px] right-[10px]">
{url && (
<div className="md:hidden rounded-xl bg-gray-300 p-2">
<Switch isSelected={enable} onValueChange={setEnable}>
Template
</Switch>
</div>
)}
</div>
);
};
#template {
width: 100px;
> * {
width: 100%;
// height: 100%;
position: absolute;
top: 0;
left: 0;
}
}
import { useEffect, useRef } from "react";
import { Template as TemplateCl } from "../../lib/template";
import { useAppContext } from "../../contexts/AppContext";
import { useTemplateContext } from "../../contexts/TemplateContext";
import { Canvas } from "../../lib/canvas";
export const Template = () => {
const { config } = useAppContext();
const { enable, url, width, setWidth, x, y, opacity, setX, setY, style } =
useTemplateContext();
const templateHolder = useRef<HTMLDivElement>(null);
const instance = useRef<TemplateCl>();
useEffect(() => {
if (!templateHolder?.current) {
console.warn("No templateHolder, cannot initialize");
return;
}
const templateHolderRef = templateHolder.current;
instance.current = new TemplateCl(config!, templateHolder.current);
instance.current.on("autoDetectWidth", (width) => {
setWidth(width);
});
let startLocation: { clientX: number; clientY: number } | undefined;
let offset: [x: number, y: number] = [0, 0];
const handleMouseDown = (e: MouseEvent) => {
if (!e.altKey) return;
startLocation = { clientX: e.clientX, clientY: e.clientY };
offset = [e.offsetX, e.offsetY];
Canvas.instance?.getPanZoom().panning.setEnabled(false);
};
const handleMouseMove = (e: MouseEvent) => {
if (!startLocation) return;
if (!Canvas.instance) {
console.warn(
"[Template#handleMouseMove] Canvas.instance is not defined"
);
return;
}
const deltaX = e.clientX - startLocation.clientX;
const deltaY = e.clientY - startLocation.clientY;
const newX = startLocation.clientX + deltaX;
const newY = startLocation.clientY + deltaY;
const [canvasX, canvasY] = Canvas.instance.screenToPos(newX, newY);
templateHolderRef.style.setProperty("left", canvasX - offset[0] + "px");
templateHolderRef.style.setProperty("top", canvasY - offset[1] + "px");
};
const handleMouseUp = (_e: MouseEvent) => {
startLocation = undefined;
Canvas.instance?.getPanZoom().panning.setEnabled(true);
const x = parseInt(
templateHolderRef.style.getPropertyValue("left").replace("px", "") ||
"0"
);
const y = parseInt(
templateHolderRef.style.getPropertyValue("top").replace("px", "") || "0"
);
setX(x);
setY(y);
};
templateHolder.current.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
instance.current?.destroy();
templateHolderRef?.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, []);
useEffect(() => {
if (!instance.current) {
console.warn(
"[Template] Received template enable but no instance exists"
);
return;
}
instance.current.setOption("enable", enable);
if (enable && url) {
instance.current.loadImage(url).then(() => {
console.log("[Template] enable: load image finished");
});
}
}, [enable]);
useEffect(() => {
if (!instance.current) {
console.warn(
"[Template] Recieved template url update but no template instance exists"
);
return;
}
if (!url) {
console.warn("[Template] Received template url blank");
return;
}
if (!enable) {
console.info("[Template] Got template URL but not enabled, ignoring");
return;
}
instance.current.loadImage(url).then(() => {
console.log("[Template] Template loader finished");
});
}, [url]);
useEffect(() => {
if (!instance.current) {
console.warn("[Template] Received template width with no instance");
return;
}
instance.current.setOption("width", width);
instance.current.rasterizeTemplate();
}, [width]);
useEffect(() => {
if (!instance.current) {
console.warn("[Template] Received style update with no instance");
return;
}
instance.current.setOption("style", style);
}, [style]);
return (
<div
id="template"
className="board-overlay"
ref={templateHolder}
style={{
top: y,
left: x,
opacity: opacity / 100,
}}
></div>
);
};
import { useTheme } from "next-themes";
import { ToastContainer } from "react-toastify";
export const ToastWrapper = () => {
const { theme } = useTheme()
return (
<ToastContainer
position="top-left"
theme={theme}
/>
);
};
\ No newline at end of file
......@@ -6,11 +6,11 @@ import {
useDisclosure,
} from "@nextui-org/react";
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
import { useAppContext } from "../contexts/AppContext";
import { Canvas } from "../lib/canvas";
import { useAppContext } from "../../contexts/AppContext";
import { Canvas } from "../../lib/canvas";
import { useEffect, useState } from "react";
import { ClientConfig } from "@sc07-canvas/lib/src/net";
import network from "../lib/network";
import network from "../../lib/network";
const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
// this implementation matches the server's implementation
......@@ -18,6 +18,7 @@ const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
const cooldown = CanvasLib.getPixelCooldown(pixels.available + 1, config);
const pixelExpiresAt =
Canvas.instance?.lastPlace && Canvas.instance.lastPlace + cooldown * 1000;
const pixelCooldown = pixelExpiresAt && (Date.now() - pixelExpiresAt) / 1000;
if (!pixelCooldown) return undefined;
......@@ -27,7 +28,7 @@ const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
};
const PlaceCountdown = () => {
const { pixels, config } = useAppContext();
const { pixels, config } = useAppContext<true>();
const [timeLeft, setTimeLeft] = useState(getTimeLeft(pixels, config));
useEffect(() => {
......@@ -43,7 +44,7 @@ const PlaceCountdown = () => {
return (
<>
{timeLeft
? pixels.available + 1 < config.canvas.pixel.maxStack && timeLeft + "s"
? pixels.available < config.canvas.pixel.maxStack && timeLeft + "s"
: ""}
</>
);
......@@ -57,7 +58,7 @@ const OnlineCount = () => {
setOnline(count);
}
network.waitFor("online").then(([count]) => setOnline(count));
network.waitForState("online").then(([count]) => setOnline(count));
network.on("online", handleOnline);
return () => {
......@@ -69,22 +70,22 @@ const OnlineCount = () => {
};
export const CanvasMeta = () => {
const { canvasPosition, cursorPosition, pixels, config } = useAppContext();
const { canvasPosition, cursor, pixels, config } = useAppContext<true>();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<div id="canvas-meta">
<div id="canvas-meta" className="toolbar-box">
{canvasPosition && (
<span>
<button className="btn-link" onClick={onOpen}>
({canvasPosition.x}, {canvasPosition.y})
</button>
{cursorPosition && (
{cursor.x !== undefined && cursor.y !== undefined && (
<>
{" "}
<span className="canvas-meta--cursor-pos">
(Cursor: {cursorPosition.x}, {cursorPosition.y})
(Cursor: {cursor.x}, {cursor.y})
</span>
</>
)}
......
#pallete {
#toolbar {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
}
#pallete {
display: flex;
gap: 10px;
padding: 10px;
background-color: #fff;
z-index: 10;
position: relative;
.pallete-colors {
// display: flex;
......@@ -38,7 +40,6 @@
vertical-align: top;
font-size: 2rem;
line-height: 1;
color: #000;
border: 0;
background: transparent;
......@@ -47,7 +48,7 @@
.pallete-color {
width: 36px;
height: 36px;
border: 2px solid #000;
border: 2px solid;
border-radius: 3px;
transition: transform 0.25s;
cursor: pointer;
......
import { useEffect, useState } from "react";
import { useAppContext } from "../contexts/AppContext";
import { Canvas } from "../lib/canvas";
import { IPalleteContext } from "../types";
import { useEffect } from "react";
import { useAppContext } from "../../contexts/AppContext";
import { Canvas } from "../../lib/canvas";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { CanvasMeta } from "./CanvasMeta";
import { KeybindManager } from "../../lib/keybinds";
import { Button, Link } from "@nextui-org/react";
export const Pallete = () => {
const { config, user } = useAppContext();
const [pallete, setPallete] = useState<IPalleteContext>({});
export const Palette = () => {
const { config, user, cursor, setCursor } = useAppContext<true>();
useEffect(() => {
if (!Canvas.instance) return;
Canvas.instance?.updateCursor(cursor.color);
}, [cursor]);
Canvas.instance.updatePallete(pallete);
}, [pallete]);
useEffect(() => {
const handleDeselect = () => {
setCursor((v) => ({
...v,
color: undefined,
}));
};
return (
<div id="pallete">
<CanvasMeta />
KeybindManager.addListener("DESELECT_COLOR", handleDeselect);
return () => {
KeybindManager.removeListener("DESELECT_COLOR", handleDeselect);
};
}, []);
return (
<div id="pallete" className="bg-[#fff] dark:bg-[#000]">
<div className="pallete-colors">
<button
aria-label="Deselect Color"
className="pallete-color--deselect"
title="Deselect Color"
onClick={() => {
setPallete(({ color, ...pallete }) => {
return pallete;
setCursor(({ color, ...cursor }) => {
return cursor;
});
}}
>
......@@ -37,7 +47,7 @@ export const Pallete = () => {
<button
key={color.id}
aria-label={color.name}
className={["pallete-color", color.id === pallete.color && "active"]
className={["pallete-color", color.id === cursor.color && "active"]
.filter((a) => a)
.join(" ")}
style={{
......@@ -45,9 +55,9 @@ export const Pallete = () => {
}}
title={color.name}
onClick={() => {
setPallete((pallete) => {
setCursor((cursor) => {
return {
...pallete,
...cursor,
color: color.id,
};
});
......@@ -58,10 +68,21 @@ export const Pallete = () => {
{!user && (
<div className="pallete-user-overlay">
You are not logged in
<a href="/api/login" className="user-login">
Login
</a>
{import.meta.env.VITE_INCLUDE_EVENT_INFO ? (
<>The event has ended</>
) : (
<div className="flex gap-3 items-center">
You are not logged in
<Button
as={Link}
href="/api/login"
className="user-login"
variant="faded"
>
Login
</Button>
</div>
)}
</div>
)}
</div>
......
import { useAppContext } from "../../contexts/AppContext";
import { useTemplateContext } from "../../contexts/TemplateContext";
import { MobileTemplateButtons } from "../Templating/MobileTemplateButtons";
import { CanvasMeta } from "./CanvasMeta";
import { Palette } from "./Palette";
import { UndoButton } from "./UndoButton";
/**
* Wrapper for everything aligned at the bottom of the screen
*/
export const ToolbarWrapper = () => {
const { config } = useAppContext();
const { showMobileTools } = useTemplateContext();
if (!config) return <></>;
return (
<div id="toolbar">
<CanvasMeta />
<UndoButton />
{showMobileTools && <MobileTemplateButtons />}
<Palette />
</div>
);
};
import { Button } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import network from "../../lib/network";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
export const UndoButton = () => {
const { undo, config } = useAppContext<true>();
/**
* percentage of time left (0 <= x <= 1)
*/
const [progress, setProgress] = useState(0.5);
useEffect(() => {
if (!undo) {
setProgress(1);
return;
}
const timer = setInterval(() => {
let diff = undo.expireAt - Date.now();
let percentage = diff / config.canvas.undo.grace_period;
setProgress(percentage);
if (percentage <= 0) {
clearInterval(timer);
}
}, 100);
return () => {
clearInterval(timer);
};
}, [undo]);
// ref-ify this?
function execUndo() {
network.socket.emitWithAck("undo").then((data) => {
if (data.success) {
console.log("Undo pixel successful");
} else {
console.log("Undo pixel error", data);
switch (data.error) {
case "pixel_covered":
toast.error("You cannot undo a covered pixel");
break;
case "unavailable":
toast.error("You have no undo available");
break;
default:
toast.error("Undo error: " + data.error);
}
}
});
}
return (
<div
className="absolute z-0"
style={{
top: undo?.available && progress >= 0 ? "-10px" : "100%",
left: "50%",
transform: "translateY(-100%) translateX(-50%)",
transition: "all 0.25s ease-in-out",
}}
>
<Button onPress={execUndo}>
<span className="z-[1]">Undo</span>
<div
className="absolute top-0 left-0 h-full bg-white/50 transition-all"
style={{ width: progress * 100 + "%" }}
></div>
</Button>
</div>
);
};
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from "@nextui-org/react";
/**
* Welcome popup
*
* TODO: customization post-event (#46)
*
* @returns
*/
export const WelcomeModal = () => {
const { isOpen, onClose } = useDisclosure({
defaultOpen: !localStorage.getItem("hide_welcome"),
});
const handleClose = () => {
localStorage.setItem("hide_welcome", "true");
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
isDismissable={false}
placement="center"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Welcome</ModalHeader>
<ModalBody>
<h1 className="text-4xl text-center">Welcome to Canvas!</h1>
<p>
Canvas is a collaborative pixel placing event that uses
Fediverse accounts
</p>
<p>More information can be found in the top left</p>
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};
import {
import React, {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
} from "react";
import {
AuthSession,
ClientConfig,
IAppContext,
ICanvasPosition,
IPosition,
} from "@sc07-canvas/lib/src/net";
import { AuthSession, ClientConfig } from "@sc07-canvas/lib/src/net";
import Network from "../lib/network";
import { Spinner } from "@nextui-org/react";
import { api } from "../lib/utils";
interface IAppContext {
config?: ClientConfig;
user?: AuthSession;
connected: boolean;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursor: ICursor;
setCursor: React.Dispatch<React.SetStateAction<ICursor>>;
pixels: { available: number };
undo?: { available: true; expireAt: number };
loadChat: boolean;
setLoadChat: (v: boolean) => void;
infoSidebar: boolean;
setInfoSidebar: (v: boolean) => void;
settingsSidebar: boolean;
setSettingsSidebar: (v: boolean) => void;
pixelWhois?: { x: number; y: number; surrounding: string[][] };
setPixelWhois: (v: this["pixelWhois"]) => void;
showKeybinds: boolean;
setShowKeybinds: (v: boolean) => void;
blankOverlay: IMapOverlay;
setBlankOverlay: React.Dispatch<React.SetStateAction<IMapOverlay>>;
heatmapOverlay: IMapOverlay;
setHeatmapOverlay: React.Dispatch<React.SetStateAction<IMapOverlay>>;
pixelPulses: boolean;
setPixelPulses: (state: boolean) => void;
profile?: string; // sub
setProfile: (v?: string) => void;
hasAdmin: boolean;
showModModal: boolean;
setShowModModal: React.Dispatch<React.SetStateAction<boolean>>;
}
interface ICanvasPosition {
x: number;
y: number;
zoom: number;
}
interface ICursor {
x?: number;
y?: number;
color?: number;
}
interface IMapOverlay {
enabled: boolean;
/**
* opacity of the overlay
* 0.0 - 1.0
*/
opacity: number;
loading: boolean;
}
const appContext = createContext<IAppContext>({} as any);
export const useAppContext = () => useContext(appContext);
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
type AppContext<ConfigExists extends boolean> = ConfigExists extends true
? WithRequiredProperty<IAppContext, "config">
: IAppContext;
/**
* Get app context
*
* @template ConfigExists If the config is already known to be available in this context
* @returns
*/
export const useAppContext = <ConfigExists extends boolean = false>() =>
useContext<AppContext<ConfigExists>>(appContext as any);
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AppContext = ({ children }: PropsWithChildren) => {
const [config, setConfig] = useState<ClientConfig>(undefined as any);
const [auth, setAuth] = useState<AuthSession>();
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
const [cursorPosition, setCursorPosition] = useState<IPosition>();
const [cursor, setCursor] = useState<ICursor>({});
const [connected, setConnected] = useState(false);
// --- settings ---
const [loadChat, _setLoadChat] = useState(false);
const [pixels, setPixels] = useState({ available: 0 });
const [undo, setUndo] = useState<{ available: true; expireAt: number }>();
// overlays visible
const [infoSidebar, setInfoSidebar] = useState(false);
const [settingsSidebar, setSettingsSidebar] = useState(false);
const [pixelWhois, setPixelWhois] = useState<{
x: number;
y: number;
surrounding: string[][];
}>();
const [showKeybinds, setShowKeybinds] = useState(false);
const [blankOverlay, setBlankOverlay] = useState<IMapOverlay>({
enabled: false,
opacity: 1,
loading: false,
});
const [heatmapOverlay, setHeatmapOverlay] = useState<IMapOverlay>({
enabled: false,
opacity: 1,
loading: false,
});
const [pixelPulses, setPixelPulses] = useState<boolean>(false);
const [profile, setProfile] = useState<string>();
const [hasAdmin, setHasAdmin] = useState(false);
const [showModModal, setShowModModal] = useState(false);
useEffect(() => {
function loadSettings() {
setLoadChat(
localStorage.getItem("matrix.enable") === null
? true
: localStorage.getItem("matrix.enable") === "true"
);
}
function handleConfig(config: ClientConfig) {
console.info("Server sent config", config);
setConfig(config);
}
......@@ -40,20 +154,60 @@ export const AppContext = ({ children }: PropsWithChildren) => {
setPixels(pixels);
}
function handleUndo(
data: { available: false } | { available: true; expireAt: number }
) {
if (data.available) {
setUndo({ available: true, expireAt: data.expireAt });
} else {
setUndo(undefined);
}
}
function handleConnect() {
setConnected(true);
}
function handleDisconnect() {
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));
Network.waitForState("pixels").then(([data]) => handlePixels(data));
Network.on("pixels", handlePixels);
Network.on("undo", handleUndo);
Network.on("connected", handleConnect);
Network.on("disconnected", handleDisconnect);
Network.socket.connect();
loadSettings();
return () => {
Network.off("user", handleUser);
Network.off("config", handleConfig);
Network.off("pixels", handlePixels);
Network.off("undo", handleUndo);
Network.off("connected", handleConnect);
Network.off("disconnected", handleDisconnect);
};
}, []);
const setLoadChat = (v: boolean) => {
_setLoadChat(v);
localStorage.setItem("matrix.enable", v ? "true" : "false");
};
return (
<appContext.Provider
value={{
......@@ -61,12 +215,40 @@ export const AppContext = ({ children }: PropsWithChildren) => {
user: auth,
canvasPosition,
setCanvasPosition,
cursorPosition,
setCursorPosition,
cursor,
setCursor,
pixels,
settingsSidebar,
setSettingsSidebar,
undo,
loadChat,
setLoadChat,
connected,
hasAdmin,
pixelWhois,
setPixelWhois,
showKeybinds,
setShowKeybinds,
blankOverlay,
setBlankOverlay,
heatmapOverlay,
setHeatmapOverlay,
pixelPulses,
setPixelPulses,
profile,
setProfile,
infoSidebar,
setInfoSidebar,
showModModal,
setShowModModal,
}}
>
{config ? children : "Loading..."}
{!config && (
<div className="fixed top-0 left-0 w-full h-full z-[49] backdrop-blur-sm bg-black/30 text-white flex items-center justify-center">
<Spinner label="Loading..." />
</div>
)}
{children}
</appContext.Provider>
);
};
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useAppContext } from "./AppContext";
import { toast } from "react-toastify";
interface IMatrixUser {
userId: string;
}
export interface IChatContext {
user?: IMatrixUser;
notificationCount: number;
doLogin: () => void;
doLogout: () => Promise<void>;
}
const chatContext = createContext<IChatContext>({} as any);
export const useChatContext = () => useContext(chatContext);
export const ChatContext = ({ children }: PropsWithChildren) => {
const { config } = useAppContext();
const checkInterval = useRef<ReturnType<typeof setInterval>>();
const checkNotifs = useRef<ReturnType<typeof setInterval>>();
const [user, setUser] = useState<IMatrixUser>();
const [notifs, setNotifs] = useState(0);
const doLogin = () => {
if (!config) {
console.warn("[ChatContext#doLogin] has no config instance");
return;
}
if (user?.userId) {
console.log("[ChatContext#doLogin] user logged in, opening element...");
window.open(config.chat.element_host);
return;
}
const redirectUrl =
window.location.protocol + "//" + window.location.host + "/chat_callback";
window.addEventListener("focus", handleWindowFocus);
checkInterval.current = setInterval(checkForAccessToken, 500);
window.open(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(redirectUrl)}`,
"_blank"
);
};
const doLogout = async () => {
if (!config) {
console.warn("[ChatContext#doLogout] has no config instance");
return;
}
await fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/logout`,
{
method: "POST",
headers: {
Authorization:
"Bearer " + localStorage.getItem("matrix.access_token"),
},
}
);
localStorage.removeItem("matrix.access_token");
localStorage.removeItem("matrix.device_id");
localStorage.removeItem("matrix.user_id");
setUser(undefined);
};
useEffect(() => {
checkForAccessToken();
checkNotifs.current = setInterval(checkForNotifs, 1000 * 60);
return () => {
window.removeEventListener("focus", handleWindowFocus);
if (checkInterval.current) clearInterval(checkInterval.current);
if (checkNotifs.current) clearInterval(checkNotifs.current);
};
}, [config?.chat]);
const handleWindowFocus = () => {
console.log("[Chat] Window has gained focus");
checkForAccessToken();
};
const checkForAccessToken = () => {
const accessToken = localStorage.getItem("matrix.access_token");
const deviceId = localStorage.getItem("matrix.device_id");
const userId = localStorage.getItem("matrix.user_id");
if (!accessToken || !deviceId || !userId) return;
// access token acquired
window.removeEventListener("focus", handleWindowFocus);
if (checkInterval.current) clearInterval(checkInterval.current);
console.log("[Chat] access token has been acquired");
setUser({ userId });
toast.success("Logged into chat");
checkIfInGeneral();
};
const checkIfInGeneral = async () => {
const generalAlias = config?.chat.general_alias;
if (!generalAlias) {
console.log("[ChatContext#checkIfInGeneral] no general alias in config");
return;
}
const accessToken = localStorage.getItem("matrix.access_token");
if (!accessToken) return;
const joinReq = await fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/join/${generalAlias}`,
{
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
reason: "Auto-joined via Canvas client",
}),
}
);
const joinRes = await joinReq.json();
console.log(
"[ChatContext#checkIfInGeneral] auto-join general response",
joinRes
);
if (joinReq.status === 200) {
toast.success(`Joined chat ${decodeURIComponent(generalAlias)}!`);
} else if (joinReq.status === 403) {
toast.error(
"Failed to join general chat! " +
joinRes.errcode +
" - " +
joinRes.error
);
} else if (joinReq.status === 429) {
toast.warn("Auto-join general chat got ratelimited");
} else {
toast.error(
"Failed to join general chat! " +
joinRes.errcode +
" - " +
joinRes.error
);
}
};
const checkForNotifs = async () => {
if (!config) {
console.warn("[ChatContext#checkForNotifs] no config instance");
return;
}
const accessToken = localStorage.getItem("matrix.access_token");
if (!accessToken) return;
const notifReq = await fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/notifications?limit=10`,
{
headers: {
Authorization: "Bearer " + accessToken,
},
}
);
const notifRes = await notifReq.json();
const notificationCount =
notifRes?.notifications?.filter((n: any) => !n.read).length || 0;
setNotifs(notificationCount);
};
return (
<chatContext.Provider
value={{ user, notificationCount: notifs, doLogin, doLogout }}
>
{children}
</chatContext.Provider>
);
};
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { IRouterData, Router } from "../lib/router";
import { KeybindManager } from "../lib/keybinds";
import { TemplateStyle } from "../lib/template";
interface ITemplate {
/**
* If the template is being used
*/
enable: boolean;
/**
* URL of the template image being used
*/
url?: string;
/**
* Width of the template being displayed
*
* @default min(template.width,canvas.width)
*/
width?: number;
x: number;
y: number;
opacity: number;
style: TemplateStyle;
showMobileTools: boolean;
setEnable(v: boolean): void;
setURL(v?: string): void;
setWidth(v?: number): void;
setX(v: number): void;
setY(v: number): void;
setOpacity(v: number): void;
setStyle(style: TemplateStyle): void;
setShowMobileTools(v: boolean): void;
}
const templateContext = createContext<ITemplate>({} as any);
export const useTemplateContext = () => useContext(templateContext);
export const TemplateContext = ({ children }: PropsWithChildren) => {
const routerData = Router.get();
const [enable, setEnable] = useState(!!routerData.template?.url);
const [url, setURL] = useState<string | undefined>(routerData.template?.url);
const [width, setWidth] = useState<number | undefined>(
routerData.template?.width
);
const [x, setX] = useState(routerData.template?.x || 0);
const [y, setY] = useState(routerData.template?.y || 0);
const [opacity, setOpacity] = useState(100);
const [style, setStyle] = useState<TemplateStyle>(
routerData.template?.style || "ONE_TO_ONE"
);
const [showMobileTools, setShowMobileTools] = useState(true);
const initAt = useRef<number>();
useEffect(() => {
initAt.current = Date.now();
const handleNavigate = (data: IRouterData) => {
if (data.template) {
setEnable(true);
setURL(data.template.url);
setWidth(data.template.width);
setX(data.template.x || 0);
setY(data.template.y || 0);
setStyle(data.template.style || "ONE_TO_ONE");
} else {
setEnable(false);
}
};
const handleToggleTemplate = () => {
setEnable((en) => !en);
};
Router.on("navigate", handleNavigate);
KeybindManager.on("TOGGLE_TEMPLATE", handleToggleTemplate);
return () => {
Router.off("navigate", handleNavigate);
KeybindManager.on("TOGGLE_TEMPLATE", handleToggleTemplate);
};
}, []);
useEffect(() => {
Router.setTemplate({ enabled: enable, width, x, y, url, style });
if (!initAt.current) {
console.debug("TemplateContext updating router but no initAt");
} else if (Date.now() - initAt.current < 2 * 1000) {
console.debug(
"TemplateContext updating router too soon after init",
Date.now() - initAt.current
);
}
if (initAt.current && Date.now() - initAt.current > 2 * 1000)
Router.queueUpdate();
}, [enable, width, x, y, url, style]);
return (
<templateContext.Provider
value={{
enable,
setEnable,
url,
setURL,
width,
setWidth,
x,
setX,
y,
setY,
opacity,
setOpacity,
style,
setStyle,
showMobileTools,
setShowMobileTools,
}}
>
{children}
</templateContext.Provider>
);
};
......@@ -4,12 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>canvas</title>
<title>Canvas</title>
<link rel="stylesheet" href="./style.scss" />
</head>
<body>
<!-- Mastodon verification -->
<a href="https://social.fediverse.events/@canvas" rel="me"></a>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
......
import "./lib/sentry";
import React from "react";
import { createRoot } from "react-dom/client";
import { NextUIProvider } from "@nextui-org/react";
import { ThemeProvider } from "next-themes";
import App from "./components/App";
import * as Sentry from "@sentry/react";
let ErrorBoundary = ({ children }: React.PropsWithChildren) => <>{children}</>;
if (__SENTRY_DSN__) {
ErrorBoundary = ({ children }) => {
return <Sentry.ErrorBoundary showDialog>{children}</Sentry.ErrorBoundary>;
};
}
const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<NextUIProvider>
<App />
</NextUIProvider>
<ErrorBoundary>
<NextUIProvider>
<ThemeProvider attribute="class" defaultTheme="system">
<div className="w-screen h-screen bg-[#ddd] dark:bg-[#060606]">
<App />
</div>
</ThemeProvider>
</NextUIProvider>
</ErrorBoundary>
</React.StrictMode>
);