Commit 39eb9e08 authored by Grant's avatar Grant
Browse files

Implement Moderation UI

parent 9076e335
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@
        "@fortawesome/fontawesome-svg-core": "^6.7.2",
        "@fortawesome/free-brands-svg-icons": "^6.7.2",
        "@fortawesome/react-fontawesome": "^0.2.2",
        "@internationalized/date": "^3.6.0",
        "@quixo3/prisma-session-store": "^3.1.13",
        "@sentry/react": "^8.48.0",
        "next-themes": "^0.4.4",
+1 −0
+73 −0
Original line number Diff line number Diff line
import { useCallback, useEffect, useState } from "react";
import { KeybindManager } from "../lib/keybinds";
import { Canvas } from "../lib/canvas";
import { useModerator } from "./Moderator";

export const ModCanvasOverlay = () => {
  const [mode, setMode] = useState<"SELECT_POINT_1" | "SELECT_POINT_2">(
    "SELECT_POINT_1"
  );
  const { state, dispatch } = useModerator();

  const handleClick = useCallback(
    (e: { clientX: number; clientY: number }) => {
      const canvasPos = Canvas.instance?.screenToPos(e.clientX, e.clientY);
      if (canvasPos) {
        if (mode === "SELECT_POINT_2") {
          dispatch({ action: "OPEN_MENU" });
        }
        dispatch({
          action: mode,
          x: canvasPos[0],
          y: canvasPos[1],
        });
        setMode((m) =>
          m === "SELECT_POINT_1" ? "SELECT_POINT_2" : "SELECT_POINT_1"
        );
      }
    },
    [dispatch, mode]
  );

  const handleClear = useCallback(() => {
    dispatch({ action: "END_SELECT_AREA" });
  }, [dispatch]);

  useEffect(() => {
    KeybindManager.on("MOD_SELECT", handleClick);
    KeybindManager.on("DESELECT_COLOR", handleClear);

    return () => {
      KeybindManager.off("MOD_SELECT", handleClick);
      KeybindManager.off("DESELECT_COLOR", handleClear);
    };
  }, [handleClear, handleClick]);

  if (!state.selection?.selecting) return <></>;

  const point1 = [
    state.selection?.point1?.[0] || 0,
    state.selection?.point1?.[1] || 0,
  ];
  const point2 = [
    state.selection?.point2?.[0] || 0,
    state.selection?.point2?.[1] || 0,
  ];

  const width = point2[0] + 1 - point1[0];
  const height = point2[1] + 1 - point1[1];

  return (
    <div
      style={{
        backgroundColor: "#f00",
        position: "absolute",
        top: `${point1[1]}px`,
        left: `${point1[0]}px`,
        width: `${width}px`,
        height: `${height}px`,
      }}
      className="animate-pulse pointer-events-none"
    ></div>
  );
};
+222 −0
Original line number Diff line number Diff line
import { faHammer } from "@fortawesome/free-solid-svg-icons";
import { SidebarBase } from "../components/SidebarBase";
import { useModerator } from "./Moderator";
import { Alert, Button, Chip } from "@nextui-org/react";
import { KeybindManager } from "../lib/keybinds";
import { Keybind } from "../components/KeybindModal";
import { useCallback, useState } from "react";
import { handleError, oapi } from "../lib/utils";
import { EError, RestAPI } from "@sc07-canvas/lib";
import { UserCard } from "../components/Profile/UserCard";
import { toast } from "react-toastify";

export const ModSidebar = () => {
  const { state, dispatch } = useModerator();

  return (
    <SidebarBase
      shown={Boolean(state.showMenu)}
      setSidebarShown={(show) =>
        dispatch({ action: show ? "OPEN_MENU" : "CLOSE_MENU" })
      }
      icon={faHammer}
      title="Moderator"
      side="Left"
      noBackdrop
    >
      <div className="p-2">
        <SelectedArea />
      </div>
    </SidebarBase>
  );
};

type Pixel = RestAPI.components["schemas"]["ModPixelQuery"]["pixels"][number];

interface IPixelSummary {
  total: number;
  users: {
    user: RestAPI.components["schemas"]["SparseUser"];
    pixels: number;
    topPixels: number;
    hasMostTopPixels: boolean;
  }[];
}

const summarizePixels = (pixels: Pixel[]): IPixelSummary => {
  const users: Map<string, RestAPI.components["schemas"]["SparseUser"]> =
    new Map();
  for (const pixel of pixels) {
    users.set(pixel.userId, pixel.user);
  }

  const userPixels = pixels.reduce(
    (p, c) => {
      if (!c.isTop) return p;

      if (!p.hasOwnProperty(c.userId)) {
        p[c.userId] = 0;
      }
      p[c.userId]++;
      return p;
    },
    {} as { [k: string]: number }
  );
  const topPixelCount = Object.values(userPixels).sort((a, b) => b - a)[0];

  return {
    total: pixels.length,
    users: [...users.values()]
      .map((u) => ({
        user: u,
        pixels: pixels.filter((p) => p.userId === u.sub).length,
        topPixels: pixels.filter((p) => p.userId === u.sub && p.isTop).length,
        hasMostTopPixels:
          pixels.filter((p) => p.userId === u.sub && p.isTop).length ===
          topPixelCount,
      }))
      .sort((a, b) => b.pixels - a.pixels),
  };
};

const SelectedArea = () => {
  const { state } = useModerator();
  const [isLoading, setIsLoading] = useState(false);
  const [pixels, setPixels] = useState<Pixel[]>([]);

  const doFetchQuery = useCallback(
    (from: `${number},${number}`, to: `${number},${number}`) => {
      setIsLoading(true);
      oapi
        .GET("/mod/canvas/query", {
          params: {
            query: {
              from,
              to,
            },
          },
        })
        .then(({ data }) => {
          if (data) setPixels(data.pixels);
        })
        .finally(() => {
          setIsLoading(false);
        });
    },
    []
  );

  if (!state.selection?.point1 || !state.selection?.point2) {
    return (
      <Alert
        title="No area selected"
        description={
          <>
            Use <Keybind kb={KeybindManager.getKeybind("MOD_SELECT")[0]} /> to
            select 2 regions on the canvas
          </>
        }
      />
    );
  }

  const from = state.selection.point1;
  const to = state.selection.point2;
  const area = [to[0] + 1 - from[0], to[1] + 1 - from[1]];
  const summary = summarizePixels(pixels);

  return (
    <>
      <header>
        Canvas Selection{" "}
        <Chip>
          ({from.join(", ")}) -&gt; ({to.join(", ")})
        </Chip>
        <Chip>
          {area[0]} x {area[1]}
        </Chip>
      </header>

      <Button
        size="sm"
        isLoading={isLoading}
        onPress={() =>
          doFetchQuery(`${from[0]},${from[1]}`, `${to[0]},${to[1]}`)
        }
      >
        Inspect Pixels
      </Button>
      <UndoAreaButton from={from} to={to} />

      <div className="flex flex-col">
        {summary.users.map((user) => (
          <div key={user.user.sub}>
            <UserCard user={user.user} />
            <Chip size="sm">total pixels: {user.pixels}</Chip>
            <Chip
              size="sm"
              color={user.hasMostTopPixels ? "success" : "default"}
            >
              top pixels: {user.topPixels}
            </Chip>
          </div>
        ))}
      </div>
    </>
  );
};

const UndoAreaButton = ({
  from,
  to,
}: {
  from: [x: number, y: number];
  to: [x: number, y: number];
}) => {
  const [loading, setLoading] = useState(false);
  const [isConfirming, setIsConfirming] = useState(false);

  const doUndo = useCallback(() => {
    setLoading(true);
    oapi
      .POST("/mod/canvas/undo", {
        body: {
          start: { x: from[0], y: from[1] },
          end: { x: to[0], y: to[1] },
        },
      })
      .then(({ error, response }) => {
        if (response.ok) {
          toast.success("Undone area " + JSON.stringify([from, to]));
        } else {
          handleError(new EError(error, response.status));
        }
      })
      .finally(() => {
        setLoading(false);
        setIsConfirming(false);
      });
  }, [from, to]);

  return (
    <>
      <Button
        size="sm"
        isDisabled={isConfirming}
        onPress={() => setIsConfirming(true)}
      >
        Undo Area
      </Button>
      {isConfirming && (
        <>
          <Button size="sm" onPress={() => setIsConfirming(false)}>
            Cancel Undo
          </Button>
          <Button size="sm" color="danger" isLoading={loading} onPress={doUndo}>
            Confirm Undo
          </Button>
        </>
      )}
    </>
  );
};
+154 −0
Original line number Diff line number Diff line
import React, { createContext, useContext, useEffect, useReducer } from "react";
import { UserModSidebar } from "./UserModSidebar";
import { KeybindManager } from "../lib/keybinds";
import { ModSidebar } from "./ModSidebar";
import { useHasRole } from "../hooks/useHasRole";

const context = createContext<{
  state: IModeratorContext;
  dispatch: React.ActionDispatch<[ModerationActions]>;
}>({
  state: {
    showMenu: false,
  },
  dispatch: () => {},
});

interface IModeratorContext {
  inspectUser?: string;
  showMenu: boolean;
  selection?: {
    selecting: boolean;
    point1?: [x: number, y: number];
    point2?: [x: number, y: number];
  };
}

type ModerationActions =
  | { action: "INSPECT_USER"; user?: string }
  | { action: "OPEN_MENU" }
  | { action: "CLOSE_MENU" }
  | { action: "START_SELECT_AREA" }
  | { action: "END_SELECT_AREA" }
  | { action: "SELECT_POINT_1"; x: number; y: number }
  | { action: "SELECT_POINT_2"; x: number; y: number };

export const useModerator = () => useContext(context);

export const ModeratorContext = ({ children }: React.PropsWithChildren) => {
  const isMod = useHasRole("MOD");

  const [state, dispatch] = useReducer<IModeratorContext, [ModerationActions]>(
    (state, data) => {
      switch (data.action) {
        case "INSPECT_USER":
          return {
            ...state,
            inspectUser: data.user,
          };
        case "OPEN_MENU":
          return {
            ...state,
            showMenu: true,
          };
        case "CLOSE_MENU":
          return {
            ...state,
            showMenu: false,
          };
        case "START_SELECT_AREA":
          return {
            ...state,
            selection: {
              selecting: true,
              // intentional overwrite of existing properties
            },
          };
        case "END_SELECT_AREA":
          return {
            ...state,
            selection: {
              selecting: false,
              // intentional overwrite of existing properties
            },
          };
        case "SELECT_POINT_1":
          return {
            ...state,
            selection: {
              selecting: true,
              point1: [data.x, data.y],
              point2: undefined,
            },
          };
        case "SELECT_POINT_2":
          return {
            ...state,
            selection: {
              ...state.selection,
              selecting: true,
              point2: [data.x, data.y],
            },
          };
      }

      throw new Error(
        "Unknown ModeratorContext action: " + JSON.stringify(data)
      );
    },
    {
      inspectUser: undefined,
      showMenu: false,
    }
  );

  useEffect(() => {
    const handleKeybind = () => {
      if (!isMod) {
        console.warn("TOGGLE_MOD_MENU canceled, user is not a moderator");
        return;
      }
      dispatch({ action: "OPEN_MENU" });
    };

    KeybindManager.on("TOGGLE_MOD_MENU", handleKeybind);

    return () => {
      KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind);
    };
  }, [isMod]);

  return (
    <context.Provider value={{ state, dispatch }}>{children}</context.Provider>
  );
};

export const Moderator = () => {
  return (
    <>
      <ModSidebar />
      <UserModSidebar />
    </>
  );
};

interface AdminUIUrls {
  ip_lookup: [address: string];
  user: [sub: string];
}

export class ModeratorUtils {
  static url<T extends keyof AdminUIUrls>(
    which: T,
    ...args: AdminUIUrls[T]
  ): string {
    switch (which) {
      case "ip_lookup":
        return `/admin/accounts?filter[ip]=${encodeURIComponent(args[0])}`;
      case "user":
        return `/admin/accounts/${encodeURIComponent(args[0])}`;
    }

    throw new Error("Unknown URL: " + which);
  }
}
Loading