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:
freesemar93
2025-08-18 12:13:30 +00:00
parent e37c40e945
commit de5e7bfc6c
78 changed files with 16126 additions and 0 deletions

71
server/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
const start = Date.now();
const path = req.path;
let capturedJsonResponse: Record<string, any> | undefined = undefined;
const originalResJson = res.json;
res.json = function (bodyJson, ...args) {
capturedJsonResponse = bodyJson;
return originalResJson.apply(res, [bodyJson, ...args]);
};
res.on("finish", () => {
const duration = Date.now() - start;
if (path.startsWith("/api")) {
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
}
if (logLine.length > 80) {
logLine = logLine.slice(0, 79) + "…";
}
log(logLine);
}
});
next();
});
(async () => {
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
res.status(status).json({ message });
throw err;
});
// importantly only setup vite in development and after
// setting up all the other routes so the catch-all route
// doesn't interfere with the other routes
if (app.get("env") === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
// ALWAYS serve the app on the port specified in the environment variable PORT
// Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10);
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
});
})();

157
server/routes.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { WebSocketServer, WebSocket } from "ws";
import { storage } from "./storage";
import { insertPixelSchema, insertCanvasConfigSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema";
import { randomUUID } from "crypto";
export async function registerRoutes(app: Express): Promise<Server> {
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
let connectedUsers = new Set<WebSocket>();
// API Routes
app.get("/api/pixels", async (req, res) => {
try {
const pixels = await storage.getAllPixels();
res.json(pixels);
} catch (error) {
res.status(500).json({ message: "Failed to fetch pixels" });
}
});
app.get("/api/config", async (req, res) => {
try {
const config = await storage.getCanvasConfig();
res.json(config);
} catch (error) {
res.status(500).json({ message: "Failed to fetch config" });
}
});
app.post("/api/config", async (req, res) => {
try {
const configData = insertCanvasConfigSchema.parse(req.body);
const config = await storage.updateCanvasConfig(configData);
// Broadcast config update to all connected clients
broadcast({
type: "config_updated",
data: configData,
});
res.json(config);
} catch (error) {
res.status(400).json({ message: "Invalid config data" });
}
});
app.post("/api/pixels", async (req, res) => {
try {
const pixelData = insertPixelSchema.parse(req.body);
const config = await storage.getCanvasConfig();
// Validate coordinates
if (pixelData.x < 0 || pixelData.x >= config.canvasWidth ||
pixelData.y < 0 || pixelData.y >= config.canvasHeight) {
return res.status(400).json({ message: "Pixel coordinates out of bounds" });
}
// Check cooldown unless events are enabled
if (!config.enableAutomaticEvents) {
const cooldown = await storage.getUserCooldown(pixelData.userId);
if (cooldown && cooldown.cooldownEnds > new Date()) {
return res.status(429).json({ message: "Cooldown active" });
}
// Set new cooldown
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
await storage.setUserCooldown({
userId: pixelData.userId,
cooldownEnds: cooldownEnd,
});
}
const pixel = await storage.placePixel(pixelData);
// Broadcast pixel placement to all connected clients
broadcast({
type: "pixel_placed",
data: {
x: pixel.x,
y: pixel.y,
color: pixel.color,
userId: pixel.userId,
username: pixel.username,
timestamp: pixel.createdAt.toISOString(),
},
});
res.json(pixel);
} catch (error) {
res.status(400).json({ message: "Invalid pixel data" });
}
});
app.get("/api/recent", async (req, res) => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const recent = await storage.getRecentPlacements(limit);
res.json(recent);
} catch (error) {
res.status(500).json({ message: "Failed to fetch recent placements" });
}
});
app.get("/api/cooldown/:userId", async (req, res) => {
try {
const { userId } = req.params;
const cooldown = await storage.getUserCooldown(userId);
const config = await storage.getCanvasConfig();
if (!cooldown || config.enableAutomaticEvents) {
return res.json({ remainingSeconds: 0 });
}
const remaining = Math.max(0, Math.ceil((cooldown.cooldownEnds.getTime() - Date.now()) / 1000));
res.json({ remainingSeconds: remaining });
} catch (error) {
res.status(500).json({ message: "Failed to fetch cooldown" });
}
});
// WebSocket handling
function broadcast(message: WSMessage) {
const messageStr = JSON.stringify(message);
connectedUsers.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(messageStr);
}
});
}
function broadcastUserCount() {
broadcast({
type: "user_count",
data: { count: connectedUsers.size },
});
}
wss.on('connection', (ws) => {
connectedUsers.add(ws);
broadcastUserCount();
ws.on('close', () => {
connectedUsers.delete(ws);
broadcastUserCount();
});
ws.on('error', () => {
connectedUsers.delete(ws);
broadcastUserCount();
});
});
return httpServer;
}

104
server/storage.ts Normal file
View File

@@ -0,0 +1,104 @@
import { type Pixel, type InsertPixel, type CanvasConfig, type InsertCanvasConfig, type UserCooldown, type InsertUserCooldown } from "@shared/schema";
import { randomUUID } from "crypto";
export interface IStorage {
// Pixel operations
getPixel(x: number, y: number): Promise<Pixel | undefined>;
getAllPixels(): Promise<Pixel[]>;
placePixel(pixel: InsertPixel): Promise<Pixel>;
// Config operations
getCanvasConfig(): Promise<CanvasConfig>;
updateCanvasConfig(config: InsertCanvasConfig): Promise<CanvasConfig>;
// User cooldown operations
getUserCooldown(userId: string): Promise<UserCooldown | undefined>;
setUserCooldown(cooldown: InsertUserCooldown): Promise<UserCooldown>;
// Recent activity
getRecentPlacements(limit?: number): Promise<Pixel[]>;
}
export class MemStorage implements IStorage {
private pixels: Map<string, Pixel>;
private config: CanvasConfig;
private userCooldowns: Map<string, UserCooldown>;
constructor() {
this.pixels = new Map();
this.userCooldowns = new Map();
this.config = {
id: randomUUID(),
canvasWidth: 100,
canvasHeight: 100,
defaultCooldown: 5,
enableAutomaticEvents: false,
eventDuration: 30,
eventInterval: 6,
showGridByDefault: true,
updatedAt: new Date(),
};
}
private getPixelKey(x: number, y: number): string {
return `${x},${y}`;
}
async getPixel(x: number, y: number): Promise<Pixel | undefined> {
return this.pixels.get(this.getPixelKey(x, y));
}
async getAllPixels(): Promise<Pixel[]> {
return Array.from(this.pixels.values());
}
async placePixel(insertPixel: InsertPixel): Promise<Pixel> {
const id = randomUUID();
const pixel: Pixel = {
...insertPixel,
id,
createdAt: new Date(),
};
this.pixels.set(this.getPixelKey(pixel.x, pixel.y), pixel);
return pixel;
}
async getCanvasConfig(): Promise<CanvasConfig> {
return this.config;
}
async updateCanvasConfig(configUpdate: InsertCanvasConfig): Promise<CanvasConfig> {
this.config = {
...this.config,
...configUpdate,
updatedAt: new Date(),
};
return this.config;
}
async getUserCooldown(userId: string): Promise<UserCooldown | undefined> {
return this.userCooldowns.get(userId);
}
async setUserCooldown(insertCooldown: InsertUserCooldown): Promise<UserCooldown> {
const id = randomUUID();
const cooldown: UserCooldown = {
...insertCooldown,
id,
lastPlacement: new Date(),
};
this.userCooldowns.set(cooldown.userId, cooldown);
return cooldown;
}
async getRecentPlacements(limit: number = 10): Promise<Pixel[]> {
const allPixels = Array.from(this.pixels.values());
return allPixels
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
}
export const storage = new MemStorage();

85
server/vite.ts Normal file
View File

@@ -0,0 +1,85 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath = path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}