Commit 6f7aad5d authored by Grant's avatar Grant
Browse files

pixel whois, keybinds & fix coords not showing on hover (CanvasMeta) (related #11)

parent 28cadf07
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -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"));

@@ -34,6 +36,7 @@ const AppInner = () => {

      <DebugModal />
      <SettingsSidebar />
      <PixelWhoisSidebar />
      <AuthErrors />

      <ToastContainer position="top-left" />
+65 −1
Original line number Diff line number Diff line
@@ -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();
@@ -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]);
@@ -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();
    };

+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}
    />
  );
};
+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>
  );
};
+7 −0
Original line number Diff line number Diff line
@@ -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);

@@ -130,6 +135,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
        setLoadChat,
        connected,
        hasAdmin,
        pixelWhois,
        setPixelWhois,
      }}
    >
      {!config && (
Loading