Loading packages/client/src/components/App.tsx +3 −0 Original line number Diff line number Diff line Loading @@ -11,6 +11,8 @@ import { ChatContext } from "../contexts/ChatContext"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; import { AuthErrors } from "./AuthErrors"; import "../lib/keybinds"; import { PixelWhoisSidebar } from "./PixelWhoisSidebar"; const Chat = lazy(() => import("./Chat/Chat")); Loading @@ -34,6 +36,7 @@ const AppInner = () => { <DebugModal /> <SettingsSidebar /> <PixelWhoisSidebar /> <AuthErrors /> <ToastContainer position="top-left" /> Loading packages/client/src/components/CanvasWrapper.tsx +65 −1 Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ import throttle from "lodash.throttle"; import { IPosition } from "@sc07-canvas/lib/src/net"; import { Template } from "./Template"; import { IRouterData, Router } from "../lib/router"; import { KeybindManager } from "../lib/keybinds"; export const CanvasWrapper = () => { const { config } = useAppContext(); Loading @@ -26,14 +27,64 @@ export const CanvasWrapper = () => { const CanvasInner = () => { const canvasRef = useRef<HTMLCanvasElement | null>(); const canvas = useRef<Canvas>(); const { config, setCanvasPosition, setCursorPosition } = useAppContext(); const { config, setCanvasPosition, setCursorPosition, setPixelWhois } = useAppContext(); const PanZoom = useContext(RendererContext); useEffect(() => { if (!canvasRef.current) return; canvas.current = new Canvas(canvasRef.current!, PanZoom); const handlePixelWhois = ({ clientX, clientY, }: { clientX: number; clientY: number; }) => { if (!canvas.current) { console.warn( "[CanvasWrapper#handlePixelWhois] canvas instance does not exist" ); return; } const [x, y] = canvas.current.screenToPos(clientX, clientY); if (x < 0 || y < 0) return; // discard if out of bounds // 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(); if (x >= width || y >= height) return; // out of bounds } else { // although this should never happen, log it console.warn( "[CanvasWrapper#handlePixelWhois] canvas config is not available yet" ); } // ....... // ....... // ....... // ...x... // ....... // ....... // ....... const surrounding = canvas.current.getSurroundingPixels(x, y, 3); setPixelWhois({ x, y, surrounding }); }; KeybindManager.on("PIXEL_WHOIS", handlePixelWhois); return () => { KeybindManager.off("PIXEL_WHOIS", handlePixelWhois); canvas.current!.destroy(); }; }, [PanZoom, setCursorPosition]); Loading Loading @@ -139,6 +190,19 @@ const CanvasInner = () => { ); } 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(); }; Loading packages/client/src/components/PixelWhoisSidebar.tsx 0 → 100644 +188 −0 Original line number Diff line number Diff line import { Button, Spinner } from "@nextui-org/react"; import { useAppContext } from "../contexts/AppContext"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { ComponentPropsWithoutRef, useEffect, useRef, useState } from "react"; import { api } from "../lib/utils"; import { UserCard } from "./Profile/UserCard"; interface IPixel { userId: string; x: number; y: number; color: string; createdAt: Date; } interface IUser { sub: string; display_name?: string; picture_url?: string; profile_url?: string; isAdmin: boolean; isModerator: boolean; } interface IInstance { hostname: string; name?: string; logo_url?: string; banner_url?: string; } export const PixelWhoisSidebar = () => { const { pixelWhois, setPixelWhois } = useAppContext(); const [loading, setLoading] = useState(true); const [whois, setWhois] = useState<{ pixel: IPixel; otherPixels: number; user: IUser | null; instance: IInstance | null; }>(); useEffect(() => { if (!pixelWhois) return; setLoading(true); setWhois(undefined); api< { pixel: IPixel; otherPixels: number; user: IUser | null; instance: IInstance | null; }, "no_pixel" >(`/api/canvas/pixel/${pixelWhois.x}/${pixelWhois.y}`) .then(({ status, data }) => { if (status === 200) { if (data.success) { setWhois({ pixel: data.pixel, otherPixels: data.otherPixels, user: data.user, instance: data.instance, }); } else { // error wahhhhhh } } else { // error wahhhh } }) .finally(() => { setLoading(false); }); }, [pixelWhois]); return ( <div className="sidebar sidebar-right" style={{ ...(pixelWhois ? {} : { display: "none" }) }} > {loading && ( <div className="absolute top-0 left-0 w-full h-full bg-black/25 flex justify-center items-center z-50"> <div className="flex flex-col bg-white p-5 rounded-lg gap-5"> <Spinner /> Loading </div> </div> )} <header> <h1>Pixel Whois</h1> <div className="flex-grow"></div> <Button size="sm" isIconOnly onClick={() => setPixelWhois(undefined)}> <FontAwesomeIcon icon={faXmark} /> </Button> </header> <div className="w-full h-52 bg-gray-200 flex justify-center items-center"> <div className="w-[128px] h-[128px] bg-white"> <SmallCanvas surrounding={pixelWhois?.surrounding} style={{ width: "100%" }} /> </div> </div> <section>{whois?.user && <UserCard user={whois.user} />}</section> <section> <table className="w-full"> <tr> <th>Placed At</th> <td>{whois?.pixel.createdAt?.toString()}</td> </tr> <tr> <th>Covered Pixels</th> <td>{whois?.otherPixels}</td> </tr> </table> </section> </div> ); }; const SmallCanvas = ({ surrounding, ...props }: { surrounding: string[][] | undefined; } & ComponentPropsWithoutRef<"canvas">) => { const canvasRef = useRef<HTMLCanvasElement | null>(null); useEffect(() => { if (!canvasRef.current) { console.warn("[SmallCanvas] canvasRef unavailable"); return; } const ctx = canvasRef.current.getContext("2d"); if (!ctx) { console.warn("[SmallCanvas] canvas context unavailable"); return; } ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); ctx.fillStyle = "rgba(0,0,0,0.2)"; ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); if (surrounding) { const PIXEL_WIDTH = canvasRef.current.width / surrounding[0].length; const middle: [x: number, y: number] = [ Math.floor(surrounding[0].length / 2), Math.floor(surrounding.length / 2), ]; for (let y = 0; y < surrounding.length; y++) { for (let x = 0; x < surrounding[y].length; x++) { let color = surrounding[y][x]; ctx.beginPath(); ctx.rect(x * PIXEL_WIDTH, y * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH); ctx.fillStyle = color; ctx.fill(); } } ctx.beginPath(); ctx.rect( middle[0] * PIXEL_WIDTH, middle[1] * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH ); ctx.strokeStyle = "#f00"; ctx.lineWidth = 4; ctx.stroke(); } }, [surrounding]); return ( <canvas width={300} height={300} ref={(r) => (canvasRef.current = r)} {...props} /> ); }; packages/client/src/components/Profile/UserCard.tsx 0 → 100644 +100 −0 Original line number Diff line number Diff line import { faMessage, faWarning, faX } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button, Link, Spinner } from "@nextui-org/react"; import { MouseEvent, useEffect, useState } from "react"; import { toast } from "react-toastify"; interface IUser { sub: string; display_name?: string; picture_url?: string; profile_url?: string; isAdmin: boolean; isModerator: boolean; } const MATRIX_HOST = import.meta.env.VITE_MATRIX_HOST!; // eg aftermath.gg const ELEMENT_HOST = import.meta.env.VITE_ELEMENT_HOST!; // eg https://chat.fediverse.events const getMatrixLink = (user: IUser) => { return `${ELEMENT_HOST}/#/user/@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`; }; /** * 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 [messageStatus, setMessageStatus] = useState< "loading" | "no_account" | "has_account" | "error" >("loading"); useEffect(() => { setMessageStatus("loading"); fetch( `https://${MATRIX_HOST}/_matrix/client/v3/profile/${encodeURIComponent(`@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`)}` ) .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]); const handleMatrixClick = (e: MouseEvent) => { if (messageStatus === "no_account") { e.preventDefault(); toast.info("This user has not setup chat yet, you cannot message them"); } }; return ( <div className="flex flex-col gap-1"> <div className="flex flex-row gap-2"> <img src={user?.picture_url} alt={`${user?.sub}'s profile`} className="w-12 h-12" /> <div className="flex flex-col gap-0.25 grow"> <span>{user?.display_name}</span> <span className="text-sm">{user?.sub}</span> </div> <div> <Button isIconOnly as={Link} href={getMatrixLink(user)} target="_blank" onClick={handleMatrixClick} > {messageStatus === "loading" ? ( <Spinner /> ) : ( <FontAwesomeIcon icon={messageStatus === "error" ? faWarning : faMessage} color="inherit" /> )} </Button> </div> </div> <Button size="sm">View Profile</Button> </div> ); }; packages/client/src/contexts/AppContext.tsx +7 −0 Original line number Diff line number Diff line Loading @@ -35,6 +35,11 @@ export const AppContext = ({ children }: PropsWithChildren) => { // overlays visible const [settingsSidebar, setSettingsSidebar] = useState(false); const [pixelWhois, setPixelWhois] = useState<{ x: number; y: number; surrounding: string[][]; }>(); const [hasAdmin, setHasAdmin] = useState(false); Loading Loading @@ -130,6 +135,8 @@ export const AppContext = ({ children }: PropsWithChildren) => { setLoadChat, connected, hasAdmin, pixelWhois, setPixelWhois, }} > {!config && ( Loading Loading
packages/client/src/components/App.tsx +3 −0 Original line number Diff line number Diff line Loading @@ -11,6 +11,8 @@ import { ChatContext } from "../contexts/ChatContext"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; import { AuthErrors } from "./AuthErrors"; import "../lib/keybinds"; import { PixelWhoisSidebar } from "./PixelWhoisSidebar"; const Chat = lazy(() => import("./Chat/Chat")); Loading @@ -34,6 +36,7 @@ const AppInner = () => { <DebugModal /> <SettingsSidebar /> <PixelWhoisSidebar /> <AuthErrors /> <ToastContainer position="top-left" /> Loading
packages/client/src/components/CanvasWrapper.tsx +65 −1 Original line number Diff line number Diff line Loading @@ -8,6 +8,7 @@ import throttle from "lodash.throttle"; import { IPosition } from "@sc07-canvas/lib/src/net"; import { Template } from "./Template"; import { IRouterData, Router } from "../lib/router"; import { KeybindManager } from "../lib/keybinds"; export const CanvasWrapper = () => { const { config } = useAppContext(); Loading @@ -26,14 +27,64 @@ export const CanvasWrapper = () => { const CanvasInner = () => { const canvasRef = useRef<HTMLCanvasElement | null>(); const canvas = useRef<Canvas>(); const { config, setCanvasPosition, setCursorPosition } = useAppContext(); const { config, setCanvasPosition, setCursorPosition, setPixelWhois } = useAppContext(); const PanZoom = useContext(RendererContext); useEffect(() => { if (!canvasRef.current) return; canvas.current = new Canvas(canvasRef.current!, PanZoom); const handlePixelWhois = ({ clientX, clientY, }: { clientX: number; clientY: number; }) => { if (!canvas.current) { console.warn( "[CanvasWrapper#handlePixelWhois] canvas instance does not exist" ); return; } const [x, y] = canvas.current.screenToPos(clientX, clientY); if (x < 0 || y < 0) return; // discard if out of bounds // 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(); if (x >= width || y >= height) return; // out of bounds } else { // although this should never happen, log it console.warn( "[CanvasWrapper#handlePixelWhois] canvas config is not available yet" ); } // ....... // ....... // ....... // ...x... // ....... // ....... // ....... const surrounding = canvas.current.getSurroundingPixels(x, y, 3); setPixelWhois({ x, y, surrounding }); }; KeybindManager.on("PIXEL_WHOIS", handlePixelWhois); return () => { KeybindManager.off("PIXEL_WHOIS", handlePixelWhois); canvas.current!.destroy(); }; }, [PanZoom, setCursorPosition]); Loading Loading @@ -139,6 +190,19 @@ const CanvasInner = () => { ); } 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(); }; Loading
packages/client/src/components/PixelWhoisSidebar.tsx 0 → 100644 +188 −0 Original line number Diff line number Diff line import { Button, Spinner } from "@nextui-org/react"; import { useAppContext } from "../contexts/AppContext"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { ComponentPropsWithoutRef, useEffect, useRef, useState } from "react"; import { api } from "../lib/utils"; import { UserCard } from "./Profile/UserCard"; interface IPixel { userId: string; x: number; y: number; color: string; createdAt: Date; } interface IUser { sub: string; display_name?: string; picture_url?: string; profile_url?: string; isAdmin: boolean; isModerator: boolean; } interface IInstance { hostname: string; name?: string; logo_url?: string; banner_url?: string; } export const PixelWhoisSidebar = () => { const { pixelWhois, setPixelWhois } = useAppContext(); const [loading, setLoading] = useState(true); const [whois, setWhois] = useState<{ pixel: IPixel; otherPixels: number; user: IUser | null; instance: IInstance | null; }>(); useEffect(() => { if (!pixelWhois) return; setLoading(true); setWhois(undefined); api< { pixel: IPixel; otherPixels: number; user: IUser | null; instance: IInstance | null; }, "no_pixel" >(`/api/canvas/pixel/${pixelWhois.x}/${pixelWhois.y}`) .then(({ status, data }) => { if (status === 200) { if (data.success) { setWhois({ pixel: data.pixel, otherPixels: data.otherPixels, user: data.user, instance: data.instance, }); } else { // error wahhhhhh } } else { // error wahhhh } }) .finally(() => { setLoading(false); }); }, [pixelWhois]); return ( <div className="sidebar sidebar-right" style={{ ...(pixelWhois ? {} : { display: "none" }) }} > {loading && ( <div className="absolute top-0 left-0 w-full h-full bg-black/25 flex justify-center items-center z-50"> <div className="flex flex-col bg-white p-5 rounded-lg gap-5"> <Spinner /> Loading </div> </div> )} <header> <h1>Pixel Whois</h1> <div className="flex-grow"></div> <Button size="sm" isIconOnly onClick={() => setPixelWhois(undefined)}> <FontAwesomeIcon icon={faXmark} /> </Button> </header> <div className="w-full h-52 bg-gray-200 flex justify-center items-center"> <div className="w-[128px] h-[128px] bg-white"> <SmallCanvas surrounding={pixelWhois?.surrounding} style={{ width: "100%" }} /> </div> </div> <section>{whois?.user && <UserCard user={whois.user} />}</section> <section> <table className="w-full"> <tr> <th>Placed At</th> <td>{whois?.pixel.createdAt?.toString()}</td> </tr> <tr> <th>Covered Pixels</th> <td>{whois?.otherPixels}</td> </tr> </table> </section> </div> ); }; const SmallCanvas = ({ surrounding, ...props }: { surrounding: string[][] | undefined; } & ComponentPropsWithoutRef<"canvas">) => { const canvasRef = useRef<HTMLCanvasElement | null>(null); useEffect(() => { if (!canvasRef.current) { console.warn("[SmallCanvas] canvasRef unavailable"); return; } const ctx = canvasRef.current.getContext("2d"); if (!ctx) { console.warn("[SmallCanvas] canvas context unavailable"); return; } ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); ctx.fillStyle = "rgba(0,0,0,0.2)"; ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); if (surrounding) { const PIXEL_WIDTH = canvasRef.current.width / surrounding[0].length; const middle: [x: number, y: number] = [ Math.floor(surrounding[0].length / 2), Math.floor(surrounding.length / 2), ]; for (let y = 0; y < surrounding.length; y++) { for (let x = 0; x < surrounding[y].length; x++) { let color = surrounding[y][x]; ctx.beginPath(); ctx.rect(x * PIXEL_WIDTH, y * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH); ctx.fillStyle = color; ctx.fill(); } } ctx.beginPath(); ctx.rect( middle[0] * PIXEL_WIDTH, middle[1] * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH ); ctx.strokeStyle = "#f00"; ctx.lineWidth = 4; ctx.stroke(); } }, [surrounding]); return ( <canvas width={300} height={300} ref={(r) => (canvasRef.current = r)} {...props} /> ); };
packages/client/src/components/Profile/UserCard.tsx 0 → 100644 +100 −0 Original line number Diff line number Diff line import { faMessage, faWarning, faX } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button, Link, Spinner } from "@nextui-org/react"; import { MouseEvent, useEffect, useState } from "react"; import { toast } from "react-toastify"; interface IUser { sub: string; display_name?: string; picture_url?: string; profile_url?: string; isAdmin: boolean; isModerator: boolean; } const MATRIX_HOST = import.meta.env.VITE_MATRIX_HOST!; // eg aftermath.gg const ELEMENT_HOST = import.meta.env.VITE_ELEMENT_HOST!; // eg https://chat.fediverse.events const getMatrixLink = (user: IUser) => { return `${ELEMENT_HOST}/#/user/@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`; }; /** * 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 [messageStatus, setMessageStatus] = useState< "loading" | "no_account" | "has_account" | "error" >("loading"); useEffect(() => { setMessageStatus("loading"); fetch( `https://${MATRIX_HOST}/_matrix/client/v3/profile/${encodeURIComponent(`@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`)}` ) .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]); const handleMatrixClick = (e: MouseEvent) => { if (messageStatus === "no_account") { e.preventDefault(); toast.info("This user has not setup chat yet, you cannot message them"); } }; return ( <div className="flex flex-col gap-1"> <div className="flex flex-row gap-2"> <img src={user?.picture_url} alt={`${user?.sub}'s profile`} className="w-12 h-12" /> <div className="flex flex-col gap-0.25 grow"> <span>{user?.display_name}</span> <span className="text-sm">{user?.sub}</span> </div> <div> <Button isIconOnly as={Link} href={getMatrixLink(user)} target="_blank" onClick={handleMatrixClick} > {messageStatus === "loading" ? ( <Spinner /> ) : ( <FontAwesomeIcon icon={messageStatus === "error" ? faWarning : faMessage} color="inherit" /> )} </Button> </div> </div> <Button size="sm">View Profile</Button> </div> ); };
packages/client/src/contexts/AppContext.tsx +7 −0 Original line number Diff line number Diff line Loading @@ -35,6 +35,11 @@ export const AppContext = ({ children }: PropsWithChildren) => { // overlays visible const [settingsSidebar, setSettingsSidebar] = useState(false); const [pixelWhois, setPixelWhois] = useState<{ x: number; y: number; surrounding: string[][]; }>(); const [hasAdmin, setHasAdmin] = useState(false); Loading Loading @@ -130,6 +135,8 @@ export const AppContext = ({ children }: PropsWithChildren) => { setLoadChat, connected, hasAdmin, pixelWhois, setPixelWhois, }} > {!config && ( Loading