Files
place_maxlan/client/src/pages/canvas.tsx
freesemar93 7968b56976 Allow configuration through a file and export images automatically
Introduce a configuration file for all settings, remove the web-based config editor, fix the grid display, and add automatic hourly PNG exports.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 0385ea33-cde8-4bbd-8fce-8d192d30eb41
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/870d08ce-da3b-4822-9874-c2fe2b7628b1/0385ea33-cde8-4bbd-8fce-8d192d30eb41/Zffw2vY
2025-08-18 12:20:13 +00:00

209 lines
6.4 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
import { Canvas } from "@/components/canvas";
import { ColorPalette } from "@/components/color-palette";
import { Button } from "@/components/ui/button";
import { useWebSocket } from "@/hooks/use-websocket";
import { useToast } from "@/hooks/use-toast";
import { Grid } from "lucide-react";
import { DEFAULT_SELECTED_COLOR, generateUserId, getUsername } from "@/lib/config";
import { Pixel, CanvasConfig, InsertPixel, WSMessage } from "@shared/schema";
import { apiRequest } from "@/lib/queryClient";
export default function CanvasPage() {
const [selectedColor, setSelectedColor] = useState(DEFAULT_SELECTED_COLOR);
const [showGrid, setShowGrid] = useState(true);
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [userId] = useState(() => generateUserId());
const [username] = useState(() => getUsername());
const { toast } = useToast();
// Fetch initial data
const { data: pixels = [], isLoading: pixelsLoading } = useQuery<Pixel[]>({
queryKey: ['/api/pixels'],
});
const { data: config, isLoading: configLoading } = useQuery<CanvasConfig>({
queryKey: ['/api/config'],
});
const { data: recentPlacements = [] } = useQuery<Pixel[]>({
queryKey: ['/api/recent'],
refetchInterval: 5000, // Refresh every 5 seconds
});
// WebSocket handling
const handleWebSocketMessage = useCallback((message: WSMessage) => {
switch (message.type) {
case "pixel_placed":
// Invalidate pixels cache to refetch
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
toast({
title: "Pixel placed",
description: `${message.data.username} placed a pixel at (${message.data.x}, ${message.data.y})`,
});
break;
case "cooldown_update":
if (message.data.userId === userId) {
setCooldownSeconds(message.data.remainingSeconds);
}
break;
}
}, [userId, toast]);
const { isConnected, userCount } = useWebSocket(handleWebSocketMessage);
// Pixel placement mutation
const placePixelMutation = useMutation({
mutationFn: async (pixel: InsertPixel) => {
const response = await apiRequest("POST", "/api/pixels", pixel);
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
// Start cooldown countdown
if (config && !config.enableAutomaticEvents) {
setCooldownSeconds(config.defaultCooldown);
}
},
onError: (error: any) => {
if (error.message.includes("429")) {
toast({
title: "Cooldown active",
description: "Please wait before placing another pixel.",
variant: "destructive",
});
} else {
toast({
title: "Error",
description: "Failed to place pixel. Please try again.",
variant: "destructive",
});
}
},
});
// Set initial grid state from config
useEffect(() => {
if (config) {
setShowGrid(config.showGridByDefault);
}
}, [config]);
// Cooldown countdown
useEffect(() => {
if (cooldownSeconds > 0) {
const timer = setTimeout(() => {
setCooldownSeconds(prev => Math.max(0, prev - 1));
}, 1000);
return () => clearTimeout(timer);
}
}, [cooldownSeconds]);
// Fetch initial cooldown state
useEffect(() => {
const fetchCooldown = async () => {
try {
const response = await fetch(`/api/cooldown/${userId}`);
const data = await response.json();
setCooldownSeconds(data.remainingSeconds);
} catch (error) {
console.error("Failed to fetch cooldown:", error);
}
};
if (userId) {
fetchCooldown();
}
}, [userId]);
const handlePixelClick = (x: number, y: number) => {
if (cooldownSeconds > 0) return;
placePixelMutation.mutate({
x,
y,
color: selectedColor,
userId,
username,
});
};
if (pixelsLoading || configLoading || !config) {
return (
<div className="min-h-screen bg-canvas-bg text-white flex items-center justify-center">
<div className="text-lg">Loading canvas...</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-canvas-bg text-white">
{/* Header */}
<header className="bg-panel-bg border-b border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-4">
<h1 className="text-xl font-bold text-white">r/place Clone</h1>
<div className="flex items-center space-x-2 text-sm text-muted">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-success' : 'bg-red-500'}`} />
<span data-testid="user-count">{userCount}</span>
<span>users online</span>
</div>
</div>
<div className="flex items-center space-x-3">
{/* Grid Toggle */}
<Button
onClick={() => setShowGrid(!showGrid)}
className="flex items-center space-x-2 bg-panel-hover hover:bg-gray-600"
variant="secondary"
data-testid="button-grid-toggle"
>
<Grid className="w-4 h-4" />
<span>Grid</span>
</Button>
{/* User Info */}
<div className="flex items-center space-x-2 px-3 py-2 bg-panel-hover rounded-lg">
<div className="w-2 h-2 bg-success rounded-full" />
<span className="text-sm" data-testid="username">{username}</span>
</div>
</div>
</header>
{/* Main Canvas Area */}
<div className="flex-1 flex overflow-hidden">
<Canvas
pixels={pixels}
selectedColor={selectedColor}
canvasWidth={config.canvasWidth}
canvasHeight={config.canvasHeight}
showGrid={showGrid}
onPixelClick={handlePixelClick}
cooldownActive={cooldownSeconds > 0}
/>
<ColorPalette
selectedColor={selectedColor}
onColorSelect={setSelectedColor}
cooldownSeconds={cooldownSeconds}
recentPlacements={recentPlacements}
/>
</div>
</div>
);
}