diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9ba7f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.DS_Store +server/public +vite.config.ts.* +*.tar.gz \ No newline at end of file diff --git a/.replit b/.replit index e69de29..adcfadc 100644 --- a/.replit +++ b/.replit @@ -0,0 +1,39 @@ +modules = ["nodejs-20", "web", "postgresql-16"] +run = "npm run dev" +hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"] + +[nix] +channel = "stable-24_05" + +[deployment] +deploymentTarget = "autoscale" +build = ["npm", "run", "build"] +run = ["npm", "run", "start"] + +[[ports]] +localPort = 5000 +externalPort = 80 + +[env] +PORT = "5000" + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npm run dev" +waitForPort = 5000 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..0d05af3 --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..a253570 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,29 @@ +import { Switch, Route } from "wouter"; +import { queryClient } from "./lib/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import CanvasPage from "@/pages/canvas"; +import NotFound from "@/pages/not-found"; + +function Router() { + return ( + + + + + ); +} + +function App() { + return ( + + + + + + + ); +} + +export default App; diff --git a/client/src/components/canvas.tsx b/client/src/components/canvas.tsx new file mode 100644 index 0000000..37beef5 --- /dev/null +++ b/client/src/components/canvas.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from "react"; +import { Pixel } from "@shared/schema"; +import { cn } from "@/lib/utils"; + +interface CanvasProps { + pixels: Pixel[]; + selectedColor: string; + canvasWidth: number; + canvasHeight: number; + showGrid: boolean; + onPixelClick: (x: number, y: number) => void; + cooldownActive: boolean; +} + +export function Canvas({ + pixels, + selectedColor, + canvasWidth, + canvasHeight, + showGrid, + onPixelClick, + cooldownActive +}: CanvasProps) { + const containerRef = useRef(null); + const [zoom, setZoom] = useState(1); + const [pixelSize, setPixelSize] = useState(8); + + // Create pixel map for O(1) lookup + const pixelMap = new Map(); + pixels.forEach(pixel => { + pixelMap.set(`${pixel.x},${pixel.y}`, pixel.color); + }); + + const handlePixelClick = (x: number, y: number) => { + if (cooldownActive) return; + onPixelClick(x, y); + }; + + const handleZoomIn = () => { + setZoom(prev => Math.min(prev * 1.2, 3)); + }; + + const handleZoomOut = () => { + setZoom(prev => Math.max(prev / 1.2, 0.5)); + }; + + const handleResetZoom = () => { + setZoom(1); + }; + + useEffect(() => { + setPixelSize(Math.max(2, 8 * zoom)); + }, [zoom]); + + const canvasStyle = { + gridTemplateColumns: `repeat(${canvasWidth}, ${pixelSize}px)`, + gridTemplateRows: `repeat(${canvasHeight}, ${pixelSize}px)`, + width: `${canvasWidth * pixelSize}px`, + height: `${canvasHeight * pixelSize}px`, + }; + + const gridClass = showGrid ? "grid-lines" : ""; + + return ( +
+
+
+ {Array.from({ length: canvasHeight }, (_, y) => + Array.from({ length: canvasWidth }, (_, x) => { + const pixelColor = pixelMap.get(`${x},${y}`) || "#FFFFFF"; + return ( +
handlePixelClick(x, y)} + data-testid={`pixel-${x}-${y}`} + data-x={x} + data-y={y} + /> + ); + }) + )} +
+
+ + {/* Zoom Controls */} +
+ + + +
+ + {/* Cooldown Overlay */} + {cooldownActive && ( +
+ )} +
+ ); +} diff --git a/client/src/components/color-palette.tsx b/client/src/components/color-palette.tsx new file mode 100644 index 0000000..51843f7 --- /dev/null +++ b/client/src/components/color-palette.tsx @@ -0,0 +1,110 @@ +import { Pixel } from "@shared/schema"; +import { COLORS } from "@/lib/config"; +import { cn } from "@/lib/utils"; + +interface ColorPaletteProps { + selectedColor: string; + onColorSelect: (color: string) => void; + cooldownSeconds: number; + recentPlacements: Pixel[]; +} + +export function ColorPalette({ + selectedColor, + onColorSelect, + cooldownSeconds, + recentPlacements +}: ColorPaletteProps) { + const cooldownProgress = cooldownSeconds > 0 ? (cooldownSeconds / 5) * 100 : 0; + + return ( +
+
+

Color Palette

+ + {/* Selected Color Display */} +
+
+
+
+
Selected Color
+
{selectedColor}
+
+
+
+ + {/* Color Grid */} +
+ {COLORS.map((color) => ( +
+
+ + {/* Cooldown Timer */} +
+
+

Cooldown

+ + {cooldownSeconds > 0 ? `${cooldownSeconds}s remaining` : "Ready"} + +
+
+
+
+
+ {cooldownSeconds > 0 + ? `Next placement available in ${cooldownSeconds} seconds` + : "You can place a pixel now" + } +
+
+ + {/* Recent Activity */} +
+

Recent Placements

+
+ {recentPlacements.length === 0 ? ( +
No recent placements
+ ) : ( + recentPlacements.map((placement, index) => ( +
+
+ + {placement.username} + + + ({placement.x}, {placement.y}) + + + {Math.floor((Date.now() - placement.createdAt.getTime()) / 1000)}s ago + +
+ )) + )} +
+
+
+ ); +} diff --git a/client/src/components/config-modal.tsx b/client/src/components/config-modal.tsx new file mode 100644 index 0000000..4be9c47 --- /dev/null +++ b/client/src/components/config-modal.tsx @@ -0,0 +1,213 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Settings } from "lucide-react"; +import { CanvasConfig, InsertCanvasConfig } from "@shared/schema"; +import { useToast } from "@/hooks/use-toast"; + +interface ConfigModalProps { + config: CanvasConfig; + onConfigUpdate: (config: InsertCanvasConfig) => Promise; +} + +export function ConfigModal({ config, onConfigUpdate }: ConfigModalProps) { + const [open, setOpen] = useState(false); + const [formData, setFormData] = useState({ + canvasWidth: config.canvasWidth, + canvasHeight: config.canvasHeight, + defaultCooldown: config.defaultCooldown, + enableAutomaticEvents: config.enableAutomaticEvents, + eventDuration: config.eventDuration, + eventInterval: config.eventInterval, + showGridByDefault: config.showGridByDefault, + }); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + useEffect(() => { + setFormData({ + canvasWidth: config.canvasWidth, + canvasHeight: config.canvasHeight, + defaultCooldown: config.defaultCooldown, + enableAutomaticEvents: config.enableAutomaticEvents, + eventDuration: config.eventDuration, + eventInterval: config.eventInterval, + showGridByDefault: config.showGridByDefault, + }); + }, [config]); + + const handleSave = async () => { + setIsLoading(true); + try { + await onConfigUpdate(formData); + toast({ + title: "Configuration saved", + description: "Canvas settings have been updated successfully.", + }); + setOpen(false); + } catch (error) { + toast({ + title: "Error", + description: "Failed to save configuration. Please try again.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + setFormData({ + canvasWidth: 100, + canvasHeight: 100, + defaultCooldown: 5, + enableAutomaticEvents: false, + eventDuration: 30, + eventInterval: 6, + showGridByDefault: true, + }); + }; + + return ( + + + + + + + Admin Configuration + + +
+ {/* Canvas Settings */} +
+

Canvas Settings

+
+
+ + setFormData({ ...formData, canvasWidth: parseInt(e.target.value) })} + className="bg-gray-700 border-gray-600 text-white" + data-testid="input-canvas-width" + /> +
+
+ + setFormData({ ...formData, canvasHeight: parseInt(e.target.value) })} + className="bg-gray-700 border-gray-600 text-white" + data-testid="input-canvas-height" + /> +
+
+
+ + {/* Cooldown Settings */} +
+

Cooldown Settings

+
+
+ + setFormData({ ...formData, defaultCooldown: parseInt(e.target.value) })} + className="bg-gray-700 border-gray-600 text-white" + data-testid="input-default-cooldown" + /> +
+
+ setFormData({ ...formData, enableAutomaticEvents: !!checked })} + data-testid="checkbox-enable-events" + /> + +
+
+
+ + {/* Event Settings */} +
+

Event Settings

+
+
+ + setFormData({ ...formData, eventDuration: parseInt(e.target.value) })} + className="bg-gray-700 border-gray-600 text-white" + data-testid="input-event-duration" + /> +
+
+ + setFormData({ ...formData, eventInterval: parseInt(e.target.value) })} + className="bg-gray-700 border-gray-600 text-white" + data-testid="input-event-interval" + /> +
+
+
+ + {/* Grid Settings */} +
+

Grid Settings

+
+ setFormData({ ...formData, showGridByDefault: !!checked })} + data-testid="checkbox-default-grid" + /> + +
+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +} diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e6a723d --- /dev/null +++ b/client/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..8722561 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/client/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/client/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/client/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/client/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>