Merge branch 'feat-mod-square-undo' into 'main'

Moderation Square Undo

Closes #16

See merge request sc07/canvas!6
This commit is contained in:
Grant 2024-07-12 01:59:51 +00:00
commit f24ea3365a
9 changed files with 253 additions and 11 deletions

View File

@ -1,4 +1,5 @@
import {
Button,
Modal,
ModalBody,
ModalContent,
@ -9,10 +10,17 @@ import { useAppContext } from "../../contexts/AppContext";
import { useCallback, useEffect, useState } from "react";
import { KeybindManager } from "../../lib/keybinds";
import { Canvas } from "../../lib/canvas";
import { toast } from "react-toastify";
import { api, handleError } from "../../lib/utils";
export const ModModal = () => {
const { showModModal, setShowModModal, hasAdmin } = useAppContext();
const [bypassCooldown, setBypassCooldown_] = useState(false);
const [selectedCoords, setSelectedCoords] = useState<{
start: [x: number, y: number];
end: [x: number, y: number];
}>();
const [loading, setLoading] = useState(false);
useEffect(() => {
setBypassCooldown_(Canvas.instance?.getCooldownBypass() || false);
@ -33,6 +41,26 @@ export const ModModal = () => {
};
}, [hasAdmin]);
useEffect(() => {
const previousClicks = Canvas.instance?.previousCanvasClicks;
if (previousClicks && previousClicks.length === 2) {
let start: [number, number] = [previousClicks[0].x, previousClicks[0].y];
let end: [number, number] = [previousClicks[1].x, previousClicks[1].y];
if (start[0] < end[0] && start[1] < end[1]) {
setSelectedCoords({
start,
end,
});
} else {
setSelectedCoords(undefined);
}
} else {
setSelectedCoords(undefined);
}
}, [showModModal]);
const setBypassCooldown = useCallback(
(value: boolean) => {
setBypassCooldown_(value);
@ -41,6 +69,39 @@ export const ModModal = () => {
[setBypassCooldown_]
);
const doUndoArea = useCallback(() => {
if (!selectedCoords) return;
if (
!confirm(
`Are you sure you want to undo (${selectedCoords.start.join(",")}) -> (${selectedCoords.end.join(",")})\n\nThis will affect ~${(selectedCoords.end[0] - selectedCoords.start[0]) * (selectedCoords.end[1] - selectedCoords.start[1])} pixels!`
)
) {
return;
}
setLoading(true);
api("/api/admin/canvas/undo", "PUT", {
start: { x: selectedCoords.start[0], y: selectedCoords.start[1] },
end: { x: selectedCoords.end[0], y: selectedCoords.end[1] },
})
.then(({ status, data }) => {
if (status === 200) {
if (data.success) {
toast.success(
`Successfully undid area (${selectedCoords.start.join(",")}) -> (${selectedCoords.end.join(",")})`
);
} else {
handleError({ status, data });
}
} else {
handleError({ status, data });
}
})
.finally(() => {
setLoading(false);
});
}, [selectedCoords]);
return (
<Modal isOpen={showModModal} onOpenChange={setShowModModal}>
<ModalContent>
@ -54,6 +115,18 @@ export const ModModal = () => {
>
Bypass placement cooldown
</Switch>
{selectedCoords && (
<Button onPress={doUndoArea} isLoading={loading}>
Undo area ({selectedCoords.start.join(",")}) -&gt; (
{selectedCoords.end.join(",")})
</Button>
)}
{!selectedCoords && (
<>
right click two positions to get more options (first click
needs to be the top left most position)
</>
)}
</ModalBody>
</>
)}

View File

@ -194,6 +194,8 @@ export class Canvas extends EventEmitter<CanvasEvents> {
);
};
previousCanvasClicks: { x: number; y: number }[] = [];
handleMouseDown(e: ClickEvent) {
if (!e.alt && !e.ctrl && !e.meta && !e.shift && e.button === "LCLICK") {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
@ -207,6 +209,16 @@ export class Canvas extends EventEmitter<CanvasEvents> {
// shift: e.meta
// }, )
}
if (e.button === "RCLICK" && !e.alt && !e.ctrl && !e.meta && !e.shift) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
// keep track of the last X pixels right clicked
// used by the ModModal to determine areas selected
this.previousCanvasClicks.push({ x, y });
this.previousCanvasClicks = this.previousCanvasClicks.slice(-2);
}
}
handleMouseMove(e: HoverEvent) {

View File

@ -34,7 +34,7 @@ export const rgbToHex = (r: number, g: number, b: number) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const api = async <T = unknown, Error = string>(
endpoint: string,
method: "GET" | "POST" = "GET",
method: "GET" | "POST" | "PUT" = "GET",
body?: unknown
): Promise<{
status: number;

View File

@ -529,6 +529,10 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
registerMouseEvents() {
console.debug("[PanZoom] Registering mouse events to $wrapper & document");
this.$wrapper.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
// zoom
this.$wrapper.addEventListener("wheel", this._mouse_wheel, {
passive: true,

View File

@ -138,6 +138,7 @@ Enum AuditLogAction {
CANVAS_FILL
CANVAS_FREEZE
CANVAS_UNFREEZE
CANVAS_AREA_UNDO
}
Ref: Pixel.userId > User.sub

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_AREA_UNDO';

View File

@ -156,6 +156,7 @@ enum AuditLogAction {
CANVAS_FILL
CANVAS_FREEZE
CANVAS_UNFREEZE
CANVAS_AREA_UNDO
}
model AuditLog {

View File

@ -175,23 +175,123 @@ app.post("/canvas/stress", async (req, res) => {
return;
}
const style: "random" | "xygradient" = req.body.style || "random";
const width: number = req.body.width;
const height: number = req.body.height;
const user = (await User.fromAuthSession(req.session.user!))!;
const paletteColors = await prisma.paletteColor.findMany({});
let promises: Promise<any>[] = [];
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let color = Math.floor(Math.random() * 30) + 1;
SocketServer.instance.io.emit("pixel", {
x,
y,
color,
});
promises.push(
new Promise(async (res) => {
let colorIndex: number;
if (style === "xygradient") {
colorIndex =
Math.floor((x / width) * (paletteColors.length / 2)) +
Math.floor((y / height) * (paletteColors.length / 2));
} else {
colorIndex = Math.floor(Math.random() * paletteColors.length);
}
let color = paletteColors[colorIndex];
await Canvas.setPixel(user, x, y, color.hex, false);
SocketServer.instance.io.emit("pixel", {
x,
y,
color: color.id,
});
res(undefined);
})
);
}
}
await Promise.allSettled(promises);
res.send("ok");
});
/**
* Undo a square
*
* @header X-Audit
* @body start.x number
* @body start.y number
* @body end.x number
* @body end.y number
*/
app.put("/canvas/undo", async (req, res) => {
if (
typeof req.body?.start?.x !== "number" ||
typeof req.body?.start?.y !== "number"
) {
res
.status(400)
.json({ success: false, error: "start position is invalid" });
return;
}
if (
typeof req.body?.end?.x !== "number" ||
typeof req.body?.end?.y !== "number"
) {
res.status(400).json({ success: false, error: "end position is invalid" });
return;
}
const user_sub =
req.session.user!.user.username +
"@" +
req.session.user!.service.instance.hostname;
const start_position: [x: number, y: number] = [
req.body.start.x,
req.body.start.y,
];
const end_position: [x: number, y: number] = [req.body.end.x, req.body.end.y];
const width = end_position[0] - start_position[0];
const height = end_position[1] - start_position[1];
const pixels = await Canvas.undoArea(start_position, end_position);
const paletteColors = await prisma.paletteColor.findMany({});
for (const pixel of pixels) {
switch (pixel.status) {
case "fulfilled": {
const coveredPixel = pixel.value;
SocketServer.instance.io.emit("pixel", {
x: pixel.pixel.x,
y: pixel.pixel.y,
color: coveredPixel
? paletteColors.find((p) => p.hex === coveredPixel.color)?.id || -1
: -1,
});
break;
}
case "rejected":
console.log("Failed to undo pixel", pixel);
break;
}
}
const user = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(user.sub)
.doing("CANVAS_AREA_UNDO")
.reason(req.header("X-Audit") || null)
.withComment(
`Area undo (${start_position.join(",")}) -> (${end_position.join(",")})`
)
.create();
res.json({ success: true, auditLog });
});
/**
* Fill an area
*

View File

@ -131,7 +131,7 @@ class Canvas {
for (let y = 0; y < this.canvasSize[1]; y++) {
const pixel = (
await prisma.pixel.findMany({
where: { x, y },
where: { x, y, deletedAt: null },
orderBy: {
createdAt: "desc",
},
@ -163,8 +163,9 @@ class Canvas {
* Undo a pixel
* @throws Error "Pixel is not on top"
* @param pixel
* @returns the pixel that now exists at that location
*/
async undoPixel(pixel: Pixel) {
async undoPixel(pixel: Pixel): Promise<Pixel | undefined> {
if (!pixel.isTop) throw new Error("Pixel is not on top");
await prisma.pixel.update({
@ -175,9 +176,14 @@ class Canvas {
},
});
const coveringPixel = (
const coveringPixel: Pixel | undefined = (
await prisma.pixel.findMany({
where: { x: pixel.x, y: pixel.y, createdAt: { lt: pixel.createdAt } },
where: {
x: pixel.x,
y: pixel.y,
createdAt: { lt: pixel.createdAt },
deletedAt: null,
},
orderBy: { createdAt: "desc" },
take: 1,
})
@ -191,6 +197,8 @@ class Canvas {
},
});
}
return coveringPixel;
}
/**
@ -290,6 +298,47 @@ class Canvas {
});
}
/**
* Undo an area of pixels
* @param start
* @param end
* @returns
*/
async undoArea(start: [x: number, y: number], end: [x: number, y: number]) {
const now = Date.now();
Logger.info("Starting undo area...");
const pixels = await prisma.pixel.findMany({
where: {
x: {
gte: start[0],
lt: end[0],
},
y: {
gte: start[1],
lt: end[1],
},
isTop: true,
},
});
const returns = await Promise.allSettled(
pixels.map((pixel) => this.undoPixel(pixel))
);
Logger.info(
"Finished undo area in " + ((Date.now() - now) / 1000).toFixed(2) + "s"
);
return returns.map((val, i) => {
const pixel = pixels[i];
return {
pixel: { x: pixel.x, y: pixel.y },
...val,
};
});
}
async fillArea(
user: { sub: string },
start: [x: number, y: number],