Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • Ategon/canvas
  • marius851000/canvas
  • sc07/canvas
3 results
Show changes
Showing
with 1167 additions and 8 deletions
import { IAccountStanding } from "@sc07-canvas/lib/src/net";
import { useCallback, useEffect, useState } from "react";
import network from "../../lib/network";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
export const AccountStanding = () => {
const [standingInfo, setStandingInfo] = useState(false);
const [standing, setStanding] = useState<IAccountStanding | undefined>(
network.getState("standing")?.[0]
);
const handleStanding = useCallback(
(standing: IAccountStanding) => {
setStanding(standing);
},
[setStanding]
);
useEffect(() => {
network.on("standing", handleStanding);
return () => {
network.off("standing", handleStanding);
};
}, []);
return (
<>
{standing?.banned && (
<div className="bg-red-500 bg-opacity-85 border-red-700 border-1 rounded-md p-1 flex items-center gap-2">
You are banned
<br />
<Button size="sm" onPress={() => setStandingInfo(true)}>
Details
</Button>
</div>
)}
<Modal isOpen={standingInfo} onClose={() => setStandingInfo(false)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Account Standing</ModalHeader>
<ModalBody>
{standing?.banned ? (
<>
You are banned until {standing.until}
<br />
{standing.reason ? (
<>Public reason given: {standing.reason}</>
) : (
<>No reason given</>
)}
</>
) : (
<>Your account is in good standing</>
)}
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
};
import { Card, CardBody } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import { EventInfoOverlay } from "../EventInfoOverlay";
import { HeaderLeft } from "./HeaderLeft";
import { HeaderRight } from "./HeaderRight";
export const Header = () => {
const { connected } = useAppContext();
return (
<header id="main-header">
{import.meta.env.VITE_INCLUDE_EVENT_INFO && <EventInfoOverlay />}
<div className="flex justify-between w-full">
<HeaderLeft />
{!connected && (
<div>
<Card>
<CardBody>Disconnected</CardBody>
</Card>
</div>
)}
<HeaderRight />
</div>
</header>
);
};
import { Button } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import { AccountStanding } from "./AccountStanding";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Debug } from "@sc07-canvas/lib/src/debug";
import { faInfoCircle, faTools } from "@fortawesome/free-solid-svg-icons";
export const HeaderLeft = () => {
const { setInfoSidebar } = useAppContext();
return (
<div className="box gap-2 flex">
<AccountStanding />
<Button
onPress={() => setInfoSidebar(true)}
variant="faded"
>
<FontAwesomeIcon icon={faInfoCircle} />
<p>Info</p>
</Button>
{import.meta.env.DEV && (
<Button
onPress={() => Debug.openDebugTools()}
variant="faded"
>
<FontAwesomeIcon icon={faTools} />
<p>Debug Tools</p>
</Button>
)}
</div>
);
};
\ No newline at end of file
import { Button, Link } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import { User } from "./User";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ThemeSwitcher } from "./ThemeSwitcher";
import { faGear, faHammer } from "@fortawesome/free-solid-svg-icons";
import React, { lazy } from "react";
const OpenChatButton = lazy(() => import("../Chat/OpenChatButton"));
const DynamicChat = () => {
const { loadChat } = useAppContext();
return <React.Suspense>{loadChat && <OpenChatButton />}</React.Suspense>;
};
export const HeaderRight = () => {
const { setSettingsSidebar, hasAdmin } = useAppContext();
return (
<div className="box flex flex-col gap-2">
<User />
<div className="flex gap-2">
<Button onPress={() => setSettingsSidebar(true)} variant="faded">
<FontAwesomeIcon icon={faGear} />
<p>Settings</p>
</Button>
<ThemeSwitcher />
{hasAdmin && (
<Button href="/admin" target="_blank" as={Link} variant="faded">
<FontAwesomeIcon icon={faHammer} />
<p>Admin</p>
</Button>
)}
<DynamicChat />
</div>
</div>
);
};
import "@theme-toggles/react/css/Classic.css";
import { Classic } from "@theme-toggles/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@nextui-org/react";
export function ThemeSwitcher() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const [isToggled, setToggle] = useState(false);
useEffect(() => {
setMounted(true);
setToggle(theme === "dark");
}, []);
useEffect(() => {
if (isToggled) {
setTheme("dark");
} else {
setTheme("light");
}
}, [isToggled]);
if (!mounted) return null;
return (
<Button
onPress={() => {
setToggle(!isToggled);
}}
variant="faded"
>
<Classic toggled={isToggled} placeholder={undefined} />
<p>{theme === "dark" ? "Dark" : "Light"}</p>
</Button>
);
}
import { Card, User as UserElement } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
export const User = () => {
const { user } = useAppContext();
return user ? (
<div className="user-card">
<div className="user-card--overview">
<div className="user-name">{user.user.username}</div>
<div className="user-instance">{user.service.instance.hostname}</div>
</div>
<img src={user.user.profile} alt="User Avatar" className="user-avatar" />
</div>
<Card>
<UserElement
name={user.user.display_name || user.user.username}
description={user.service.instance.hostname}
avatarProps={{
showFallback: true,
name: undefined,
src: user.user.picture_url
}}
className="p-2"
/>
</Card>
) : (
<></>
);
};
};
\ No newline at end of file
import { Button, Link } from "@nextui-org/react";
import { SiDiscord, SiLemmy, SiMastodon, SiMatrix } from "@icons-pack/react-simple-icons"
export const InfoButtons = () => {
return (
<div className="p-2 flex gap-2 flex-wrap">
<Button
as={Link}
size="sm"
href="https://matrix.to/#/#canvas-meta:aftermath.gg?via=matrix.org"
target="_blank"
variant="ghost"
>
<SiMatrix size={18} />
<p>Matrix</p>
</Button>
<Button
as={Link}
size="sm"
href="https://discord.gg/mEUqXZw8kR"
target="_blank"
variant="ghost"
>
<SiDiscord size={18} />
<p>Discord</p>
</Button>
<Button
as={Link}
size="sm"
href="https://toast.ooo/c/canvas"
target="_blank"
variant="ghost"
>
<SiLemmy size={18} />
<p>Lemmy</p>
</Button>
<Button
as={Link}
size="sm"
href="https://social.fediverse.events/@canvas"
target="_blank"
variant="ghost"
>
<SiMastodon size={18} />
<p>Mastodon</p>
</Button>
</div>
);
};
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShieldHalved } from "@fortawesome/free-solid-svg-icons";
export const InfoPrivacy = () => {
return (
<div className="flex flex-col gap-2">
<header className="flex items-center gap-1">
<FontAwesomeIcon icon={faShieldHalved} size="2xs" className="pt-0.5" />
<h2 className="text">Privacy</h2>
</header>
<section>
<ul className="list-disc text-default-800 text-sm ml-10 flex flex-col gap-1">
<li>
Google Invisible Recaptcha is used to help prevent bots. Google's
privacy policy and terms are available below.
</li>
<li>Usernames should not be assumed to be private</li>
</ul>
</section>
</div>
);
};
import { faGavel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export const InfoRules = () => {
return (
<div className="flex flex-col gap-2">
<header className="flex items-center gap-1">
<FontAwesomeIcon icon={faGavel} size="2xs" className="pt-0.5" />
<h2 className="text">Rules</h2>
</header>
<section>
<ol className="list-decimal text-default-800 text-sm ml-10 flex flex-col gap-1">
<li>
<p>No alternate accounts</p>
<p className="text-xs text-default-600">We want to keep it fair and not require people to create more
accounts to defend their art</p>
</li>
<li>
<p>No bots/automated placements</p>
<p className="text-xs text-default-600">We're land of the humans, not bots</p>
</li>
<li>
<p>No hate speech or adjacent</p>
</li>
<li>
<p>No gore or nudity (NSFW/NSFL)</p>
</li>
</ol>
<p className="text-default-600 text-xs ml-3 mt-1">
This canvas is built upon good faith rules, therefore moderators have
complete discretion on the rules. If you have any questions, ask in
the Matrix space or the Discord
</p>
</section>
</div>
)
};
\ No newline at end of file
import { useAppContext } from "../../contexts/AppContext";
import { InfoText } from "./InfoText";
import { InfoButtons } from "./InfoButtons";
import { SidebarBase } from "../SidebarBase";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
/**
* Information sidebar
*
* TODO: add customization for this post-event (#46)
*
* @returns
*/
export const InfoSidebar = () => {
const { infoSidebar, setInfoSidebar } = useAppContext();
return (
<SidebarBase shown={infoSidebar} setSidebarShown={setInfoSidebar} icon={faInfoCircle} title="Info" description="Information about the event" side="Left">
<div className="flex flex-col h-full justify-between">
<div>
<InfoButtons />
<InfoText />
</div>
<div className="p-2">
<p className="text-xs text-default-600">Build {__COMMIT_HASH__}</p>
<div id="grecaptcha-badge"></div>
</div>
</div>
</SidebarBase>
)
};
\ No newline at end of file
import { InfoPrivacy } from "./InfoPrivacy";
import { InfoRules } from "./InfoRules";
import { InfoWelcome } from "./InfoWelcome";
export const InfoText = () => {
return (
<div className="flex flex-col p-5 py-1 gap-2">
<InfoWelcome />
<InfoRules />
<InfoPrivacy />
</div>
)
};
import { faFire } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export const InfoWelcome = () => {
return (
<div className="flex flex-col gap-2">
<header className="flex items-center gap-1">
<FontAwesomeIcon icon={faFire} size="2xs" className="pt-0.5" />
<h2 className="text">Welcome</h2>
</header>
<section>
<p className="text-default-600 text-xs ml-3">Welcome to canvas! This is an event that lasts for 72 hours where users can place pixels every so often on a shared canvas. Everyone has access to the same canvas and pixels can be placed in any location to create things on the canvas.</p>
</section>
</div>
)
};
\ No newline at end of file
import {
Button,
Kbd,
KbdKey,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
import { useAppContext } from "../contexts/AppContext";
import { KeybindManager } from "../lib/keybinds";
export const KeybindModal = () => {
const { showKeybinds, setShowKeybinds } = useAppContext();
return (
<Modal
isOpen={showKeybinds}
onOpenChange={setShowKeybinds}
placement="center"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Keybinds</ModalHeader>
<ModalBody>
{Object.entries(KeybindManager.getKeybinds()).map(
([name, kbs]) => (
<div className="flex flex-row gap-2">
<span>{name}</span>
{kbs.map((kb) => (
<Kbd
keys={(
[
kb.alt && "option",
kb.ctrl && "ctrl",
kb.meta && "command",
kb.shift && "shift",
] as KbdKey[]
).filter((a) => a)}
>
{kb.key}
</Kbd>
))}
</div>
)
)}
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalHeader,
Switch,
} from "@nextui-org/react";
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);
const handleKeybind = () => {
if (!hasAdmin) {
console.warn("Unable to open mod menu; hasAdmin is not set");
return;
}
setShowModModal((m) => !m);
};
KeybindManager.on("TOGGLE_MOD_MENU", handleKeybind);
return () => {
KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind);
};
}, [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);
Canvas.instance?.setCooldownBypass(value);
},
[setBypassCooldown_]
);
const doUndoArea = useCallback(() => {
if (!selectedCoords) return;
if (
!window.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>
{(_onClose) => (
<>
<ModalHeader>Mod Menu</ModalHeader>
<ModalBody>
<Switch
isSelected={bypassCooldown}
onValueChange={setBypassCooldown}
>
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>
</>
)}
</ModalContent>
</Modal>
);
};
import { useEffect, useRef } from "react";
import { useAppContext } from "../../contexts/AppContext";
import { KeybindManager } from "../../lib/keybinds";
import { getRenderer } from "../../lib/utils";
export const BlankOverlay = () => {
const { blankOverlay, setBlankOverlay } = useAppContext();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const handleKeybind = () => {
setBlankOverlay((v) => ({ ...v, enabled: !v.enabled }));
};
KeybindManager.on("TOGGLE_BLANK", handleKeybind);
return () => {
KeybindManager.off("TOGGLE_BLANK", handleKeybind);
};
}, [setBlankOverlay]);
useEffect(() => {
if (!canvasRef.current) {
return;
}
let timeout = setTimeout(() => {
if (!canvasRef.current) return;
getRenderer().useCanvas(canvasRef.current, "blank");
}, 1000);
return () => {
clearTimeout(timeout);
getRenderer().removeCanvas("blank");
};
}, [canvasRef.current]);
return (
<canvas
id="blank-overlay"
className="board-overlay no-interact pixelate"
ref={(r) => (canvasRef.current = r)}
width="1000"
height="1000"
style={{
display: blankOverlay.enabled ? "block" : "none",
opacity: blankOverlay.opacity.toFixed(1),
}}
/>
);
};
import { useCallback, useEffect, useRef } from "react";
import { useAppContext } from "../../contexts/AppContext";
import { KeybindManager } from "../../lib/keybinds";
import { api } from "../../lib/utils";
import { toast } from "react-toastify";
import network from "../../lib/network";
export const HeatmapOverlay = () => {
const { config, heatmapOverlay, setHeatmapOverlay } = useAppContext();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const handleKeybind = () => {
setHeatmapOverlay((v) => ({ ...v, enabled: !v.enabled }));
};
KeybindManager.on("TOGGLE_HEATMAP", handleKeybind);
return () => {
KeybindManager.off("TOGGLE_HEATMAP", handleKeybind);
};
}, [setHeatmapOverlay]);
useEffect(() => {
if (!config) {
console.warn("[HeatmapOverlay] config is not defined");
return;
}
if (!canvasRef.current) {
console.warn("[HeatmapOverlay] canvasRef is not defined");
return;
}
const [width, height] = config.canvas.size;
canvasRef.current.width = width;
canvasRef.current.height = height;
}, [config]);
const drawHeatmap = useCallback(
(rawData: string) => {
console.debug("[HeatmapOverlay] drawing heatmap");
if (!config) {
console.warn("[HeatmapOverlay] no config instance available");
return;
}
const ctx = canvasRef.current!.getContext("2d");
if (!ctx) {
console.warn("[HeatmapOverlay] canvas context cannot be aquired");
return;
}
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
if (heatmapOverlay.enabled) {
let heatmap = rawData.split("");
let lines: number[][] = [];
while (heatmap.length > 0) {
// each pixel is stored as 2 characters
let line = heatmap.splice(0, config?.canvas.size[0] * 2).join("");
let pixels = (line.match(/.{1,2}/g) || []).map(
(v) => parseInt(v, 36) / 100
);
lines.push(pixels);
}
for (let y = 0; y < lines.length; y++) {
for (let x = 0; x < lines[y].length; x++) {
const val = lines[y][x];
ctx.fillStyle = `rgba(255, 0, 0, ${Math.max(val, 0.1).toFixed(2)})`;
ctx.fillRect(x, y, 1, 1);
}
}
} else {
console.warn(
"[HeatmapOverlay] drawHeatmap called with heatmap disabled"
);
}
},
[config, heatmapOverlay.enabled]
);
const updateHeatmap = useCallback(() => {
setHeatmapOverlay((v) => ({ ...v, loading: true }));
api<{ heatmap: string }, "heatmap_not_generated">("/api/heatmap")
.then(({ status, data }) => {
if (status === 200 && data.success) {
drawHeatmap(data.heatmap);
} else {
if ("error" in data) {
switch (data.error) {
case "heatmap_not_generated":
toast.info("Heatmap is not generated. Try again shortly");
setHeatmapOverlay((v) => ({ ...v, enabled: false }));
break;
default:
toast.error("Unknown error: " + data.error);
}
} else {
toast.error("Failed to load heatmap: Error " + status);
}
}
})
.finally(() => {
setHeatmapOverlay((v) => ({ ...v, loading: false }));
});
}, [drawHeatmap, setHeatmapOverlay]);
useEffect(() => {
if (!canvasRef.current) {
console.warn("[HeatmapOverlay] canvasRef is not defined");
return;
}
if (heatmapOverlay.enabled) updateHeatmap();
return () => {};
}, [canvasRef, heatmapOverlay.enabled, updateHeatmap]);
useEffect(() => {
if (heatmapOverlay.enabled) {
console.debug("[HeatmapOverlay] subscribing to heatmap updates");
network.subscribe("heatmap");
} else {
console.debug("[HeatmapOverlay] unsubscribing from heatmap updates");
network.unsubscribe("heatmap");
}
network.on("heatmap", drawHeatmap);
return () => {
network.off("heatmap", drawHeatmap);
};
}, [drawHeatmap, heatmapOverlay.enabled]);
return (
<canvas
id="heatmap-overlay"
className="board-overlay no-interact pixelate"
ref={(r) => (canvasRef.current = r)}
width="1000"
height="1000"
style={{
display: heatmapOverlay.enabled ? "block" : "none",
opacity: heatmapOverlay.opacity.toFixed(1),
}}
/>
);
};
import { Slider, Spinner, Switch } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
export const OverlaySettings = () => {
const { blankOverlay, setBlankOverlay, heatmapOverlay, setHeatmapOverlay, pixelPulses, setPixelPulses } =
useAppContext();
return (
<div className="flex flex-col gap-4 p-2">
<header className="flex flex-col gap-2">
<h2 className="text-xl">Overlays</h2>
<p className="text-xs text-default-600">Overlays to display additional info over the canvas</p>
</header>
<section className="flex flex-col gap-2">
<Switch
isSelected={blankOverlay.enabled}
onValueChange={(v) =>
setBlankOverlay((vv) => ({ ...vv, enabled: v }))
}
>
Blank Canvas Overlay
</Switch>
{blankOverlay.enabled && (
<Slider
label="Blank Canvas Opacity"
step={0.1}
minValue={0}
maxValue={1}
value={blankOverlay.opacity}
onChange={(v) =>
setBlankOverlay((vv) => ({ ...vv, opacity: v as number }))
}
getValue={(v) => (v as number) * 100 + "%"}
/>
)}
<Switch
isSelected={heatmapOverlay.enabled}
onValueChange={(v) =>
setHeatmapOverlay((vv) => ({ ...vv, enabled: v }))
}
>
{heatmapOverlay.loading && <Spinner size="sm" />}
Heatmap Overlay
</Switch>
{heatmapOverlay.enabled && (
<Slider
label="Heatmap Opacity"
step={0.1}
minValue={0}
maxValue={1}
value={heatmapOverlay.opacity}
onChange={(v) =>
setHeatmapOverlay((vv) => ({ ...vv, opacity: v as number }))
}
getValue={(v) => (v as number) * 100 + "%"}
/>
)}
<Switch
isSelected={pixelPulses}
onValueChange={(v) =>
setPixelPulses(v)
}
>
New Pixel Pulses
</Switch>
</section>
</div>
);
};
import { CSSProperties, useEffect, useState } from "react";
import { useAppContext } from "../../contexts/AppContext";
import network from "../../lib/network";
import { Pixel } from "@sc07-canvas/lib/src/net";
import { Canvas } from "../../lib/canvas";
export const PixelPulses = () => {
const { pixelPulses } = useAppContext();
const [pulses, setPulses] = useState<JSX.Element[]>([]);
useEffect(() => {
function handlePixel({ x, y, color }: Pixel) {
if (!pixelPulses) {
return;
}
const paletteColor = Canvas.instance?.Pallete.getColor(color);
const pulseStyle: CSSProperties = {
position: "absolute",
zIndex: "100",
left: x + "px",
top: y + "px",
width: "50px",
height: "50px",
border: `1px solid #${paletteColor?.hex || "000"}`, // default to black in the case it fails to load, but that shouldn't happen
borderRadius: "100px",
transform: "translate(-24.5px, -24.5px)",
animationName: "pixel-pulse",
animationTimingFunction: "ease-in-out",
animationDuration: "2s",
animationFillMode: "forwards",
};
// used in the case of two pixels coming through for the same position
// rare, but causes issues with react
// even if the pixels are close to eachother, the ms will be different
const timestamp = Date.now();
const pulseElement = (
<div key={`${x}-${y}-${timestamp}`} style={pulseStyle}></div>
);
setPulses((prevPulses) => [...prevPulses, pulseElement]);
setTimeout(() => {
setPulses((prevPulses) => prevPulses.slice(1)); // Remove the oldest pulse after 3700ms
}, 2500);
}
network.on("pixel", handlePixel);
return () => {
network.off("pixel", handlePixel);
};
}, [pixelPulses]);
return <div>{pulses}</div>;
};
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 bg-white dark:bg-black text-black dark:text-white"
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 dark:bg-gray-800 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">
<tbody>
<tr>
<th>Placed At</th>
<td>{whois?.pixel.createdAt?.toString()}</td>
</tr>
<tr>
<th>Covered Pixels</th>
<td>{whois?.otherPixels}</td>
</tr>
</tbody>
</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}
/>
);
};
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import { useEffect, useState } from "react";
import { IUser, UserCard } from "./UserCard";
import { api, handleError } from "../../lib/utils";
export const ProfileModal = () => {
const { profile, setProfile } = useAppContext();
const [user, setUser] = useState<IUser>();
useEffect(() => {
if (!profile) {
setUser(undefined);
return;
}
api<{ user: IUser }>("/api/user/" + profile).then(({ status, data }) => {
if (status === 200 && data.success) {
setUser(data.user);
} else {
handleError({ status, data });
}
});
}, [profile]);
return (
<Modal isOpen={!!profile} onClose={() => setProfile()} placement="center">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Profile</ModalHeader>
<ModalBody>
{user ? <UserCard user={user} /> : <>Loading...</>}
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};