Skip to content
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>
);
};