Merge branch 'feat-mod-square-undo' into 'main'
Moderation Square Undo Closes #16 See merge request sc07/canvas!6
This commit is contained in:
commit
f24ea3365a
|
@ -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(",")}) -> (
|
||||
{selectedCoords.end.join(",")})
|
||||
</Button>
|
||||
)}
|
||||
{!selectedCoords && (
|
||||
<>
|
||||
right click two positions to get more options (first click
|
||||
needs to be the top left most position)
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -138,6 +138,7 @@ Enum AuditLogAction {
|
|||
CANVAS_FILL
|
||||
CANVAS_FREEZE
|
||||
CANVAS_UNFREEZE
|
||||
CANVAS_AREA_UNDO
|
||||
}
|
||||
|
||||
Ref: Pixel.userId > User.sub
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_AREA_UNDO';
|
|
@ -156,6 +156,7 @@ enum AuditLogAction {
|
|||
CANVAS_FILL
|
||||
CANVAS_FREEZE
|
||||
CANVAS_UNFREEZE
|
||||
CANVAS_AREA_UNDO
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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],
|
||||
|
|
Loading…
Reference in New Issue