Add basic structure for a collaborative pixel art website
Adds the core project structure, including configuration files, a basic HTML page, and the main application component. It also lays the groundwork for the canvas, color palette, and configuration modal functionalities. 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/Vuy7IOw
This commit is contained in:
220
client/src/pages/canvas.tsx
Normal file
220
client/src/pages/canvas.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
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 { ConfigModal } from "@/components/config-modal";
|
||||
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, InsertCanvasConfig, 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 "config_updated":
|
||||
// Invalidate config cache
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/config'] });
|
||||
toast({
|
||||
title: "Configuration updated",
|
||||
description: "Canvas settings have been changed by an administrator.",
|
||||
});
|
||||
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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Config update mutation
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async (configUpdate: InsertCanvasConfig) => {
|
||||
const response = await apiRequest("POST", "/api/config", configUpdate);
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/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,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfigUpdate = async (configUpdate: InsertCanvasConfig) => {
|
||||
await updateConfigMutation.mutateAsync(configUpdate);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* Admin Config Button */}
|
||||
<ConfigModal config={config} onConfigUpdate={handleConfigUpdate} />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user