Compare commits
3 Commits
b7365d2127
...
coldown-fi
| Author | SHA1 | Date | |
|---|---|---|---|
| 68eeaa063d | |||
| 49923adcd2 | |||
| d5f8de1e4c |
38
.replit
38
.replit
@@ -1,42 +1,12 @@
|
|||||||
modules = ["nodejs-20", "web", "postgresql-16"]
|
modules = ["nodejs-20", "web"]
|
||||||
run = "npm run dev"
|
run = "npm run dev"
|
||||||
hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
|
|
||||||
|
|
||||||
[nix]
|
[nix]
|
||||||
channel = "stable-24_05"
|
channel = "stable-25_05"
|
||||||
|
|
||||||
[deployment]
|
[deployment]
|
||||||
deploymentTarget = "autoscale"
|
run = ["sh", "-c", "npm run dev"]
|
||||||
build = ["npm", "run", "build"]
|
|
||||||
run = ["npm", "run", "start"]
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 5000
|
localPort = 5001
|
||||||
externalPort = 80
|
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
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
integrations = ["javascript_mem_db==1.0.0", "javascript_websocket==1.0.0"]
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ EXPORT_PATH=./exports/ # Speicherort für SVG-Exports
|
|||||||
ENABLE_AUTOMATIC_EVENTS=false # Automatische Events deaktiviert
|
ENABLE_AUTOMATIC_EVENTS=false # Automatische Events deaktiviert
|
||||||
EVENT_DURATION_MINUTES=30 # Event-Dauer
|
EVENT_DURATION_MINUTES=30 # Event-Dauer
|
||||||
EVENT_INTERVAL_HOURS=6 # Abstand zwischen Events
|
EVENT_INTERVAL_HOURS=6 # Abstand zwischen Events
|
||||||
|
|
||||||
|
# Keycloak Authentifizierung (optional)
|
||||||
|
ENABLE_KEYCLOAK=false # Keycloak-Authentifizierung
|
||||||
|
KEYCLOAK_REALM=rplace # Keycloak Realm Name
|
||||||
|
KEYCLOAK_AUTH_URL=http://localhost:8080 # Keycloak Server URL
|
||||||
|
KEYCLOAK_CLIENT_ID=rplace-client # Keycloak Client ID Events
|
||||||
```
|
```
|
||||||
|
|
||||||
## Schritt 4: PostgreSQL Datenbank einrichten (optional)
|
## Schritt 4: PostgreSQL Datenbank einrichten (optional)
|
||||||
|
|||||||
96
KEYCLOAK_SETUP.md
Normal file
96
KEYCLOAK_SETUP.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
# Keycloak Setup für r/place
|
||||||
|
|
||||||
|
## Keycloak Installation
|
||||||
|
|
||||||
|
### Docker (Empfohlen)
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name keycloak \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e KEYCLOAK_ADMIN=admin \
|
||||||
|
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||||
|
quay.io/keycloak/keycloak:latest \
|
||||||
|
start-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone Installation
|
||||||
|
1. Lade Keycloak von https://www.keycloak.org/downloads herunter
|
||||||
|
2. Entpacke das Archiv
|
||||||
|
3. Starte Keycloak: `bin/kc.sh start-dev`
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### 1. Admin Console öffnen
|
||||||
|
Gehe zu http://localhost:8080/admin und melde dich mit admin/admin an.
|
||||||
|
|
||||||
|
### 2. Realm erstellen
|
||||||
|
1. Klicke auf "Create Realm"
|
||||||
|
2. Name: `rplace`
|
||||||
|
3. Klicke "Create"
|
||||||
|
|
||||||
|
### 3. Client erstellen
|
||||||
|
1. Gehe zu "Clients" → "Create client"
|
||||||
|
2. Client ID: `rplace-client`
|
||||||
|
3. Client type: `OpenID Connect`
|
||||||
|
4. Klicke "Next"
|
||||||
|
5. Client authentication: `OFF` (Public client)
|
||||||
|
6. Standard flow: `ON`
|
||||||
|
7. Direct access grants: `ON`
|
||||||
|
8. Klicke "Save"
|
||||||
|
|
||||||
|
### 4. Client Settings
|
||||||
|
1. Gehe zu deinem Client `rplace-client`
|
||||||
|
2. Settings Tab:
|
||||||
|
- Valid redirect URIs: `http://localhost:5000/*`
|
||||||
|
- Valid post logout redirect URIs: `http://localhost:5000/*`
|
||||||
|
- Web origins: `http://localhost:5000`
|
||||||
|
3. Klicke "Save"
|
||||||
|
|
||||||
|
### 5. Test User erstellen
|
||||||
|
1. Gehe zu "Users" → "Add user"
|
||||||
|
2. Username: `testuser`
|
||||||
|
3. Klicke "Create"
|
||||||
|
4. Gehe zum "Credentials" Tab
|
||||||
|
5. Setze ein Passwort und deaktiviere "Temporary"
|
||||||
|
|
||||||
|
## r/place Konfiguration
|
||||||
|
|
||||||
|
Bearbeite `config.cfg`:
|
||||||
|
```ini
|
||||||
|
ENABLE_KEYCLOAK=true
|
||||||
|
KEYCLOAK_REALM=rplace
|
||||||
|
KEYCLOAK_AUTH_URL=http://localhost:8080
|
||||||
|
KEYCLOAK_CLIENT_ID=rplace-client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erweiterte Konfiguration
|
||||||
|
|
||||||
|
### HTTPS (Produktion)
|
||||||
|
Für Produktionsumgebungen:
|
||||||
|
```ini
|
||||||
|
KEYCLOAK_AUTH_URL=https://dein-keycloak-server.de
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benutzer-Attribute
|
||||||
|
Du kannst zusätzliche Benutzerattribute in Keycloak konfigurieren:
|
||||||
|
1. Gehe zu "Client scopes"
|
||||||
|
2. Bearbeite "profile" scope
|
||||||
|
3. Füge Mappers für zusätzliche Attribute hinzu
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Ändere Admin-Passwort
|
||||||
|
- Konfiguriere SSL/TLS
|
||||||
|
- Setze starke Passwort-Richtlinien
|
||||||
|
- Aktiviere Brute-Force-Schutz
|
||||||
|
|
||||||
|
## Fehlerbehebung
|
||||||
|
|
||||||
|
### CORS-Probleme
|
||||||
|
Stelle sicher, dass die Web origins korrekt konfiguriert sind.
|
||||||
|
|
||||||
|
### Token-Probleme
|
||||||
|
Überprüfe die Client-Konfiguration und Redirect-URIs.
|
||||||
|
|
||||||
|
### Verbindungsprobleme
|
||||||
|
Stelle sicher, dass Keycloak erreichbar ist und die URLs korrekt sind.
|
||||||
@@ -4,12 +4,14 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import CanvasPage from "@/pages/canvas";
|
import CanvasPage from "@/pages/canvas";
|
||||||
|
import AdminPage from "@/pages/admin";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={CanvasPage} />
|
<Route path="/" component={CanvasPage} />
|
||||||
|
<Route path="/admin" component={AdminPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
58
client/src/components/auth-banner.tsx
Normal file
58
client/src/components/auth-banner.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getAuthStatus, type AuthStatus } from "@/lib/config";
|
||||||
|
|
||||||
|
export function AuthBanner() {
|
||||||
|
const [authStatus, setAuthStatus] = useState<AuthStatus | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthStatus().then(setAuthStatus);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!authStatus?.keycloakEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStatus.authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="bg-green-50 border-b border-green-200 p-3">
|
||||||
|
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-700 font-medium">
|
||||||
|
Angemeldet als {authStatus.user?.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.location.href = "/logout"}
|
||||||
|
className="border-green-300 text-green-700 hover:bg-green-100"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 border-b border-blue-200 p-3">
|
||||||
|
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-blue-700">
|
||||||
|
Melde dich an, um Pixel zu platzieren
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = "/login"}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Pixel } from "@shared/schema";
|
import { Pixel } from "@shared/schema";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -22,9 +23,11 @@ export function OptimizedCanvas({
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [pixelSize, setPixelSize] = useState(8);
|
const pixelSize = Math.max(2, 8 * zoom);
|
||||||
const [mouseCoords, setMouseCoords] = useState<{x: number, y: number} | null>(null);
|
const [mouseCoords, setMouseCoords] = useState<{x: number, y: number} | null>(null);
|
||||||
const [previewPixel, setPreviewPixel] = useState<{x: number, y: number} | null>(null);
|
const [previewPixel, setPreviewPixel] = useState<{x: number, y: number} | null>(null);
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const [lastPanPosition, setLastPanPosition] = useState<{x: number, y: number} | null>(null);
|
||||||
|
|
||||||
// Create pixel map for O(1) lookup
|
// Create pixel map for O(1) lookup
|
||||||
const pixelMap = new Map<string, string>();
|
const pixelMap = new Map<string, string>();
|
||||||
@@ -117,10 +120,6 @@ export function OptimizedCanvas({
|
|||||||
drawCanvas();
|
drawCanvas();
|
||||||
}, [drawCanvas]);
|
}, [drawCanvas]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPixelSize(Math.max(2, 8 * zoom));
|
|
||||||
}, [zoom]);
|
|
||||||
|
|
||||||
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
@@ -158,18 +157,114 @@ export function OptimizedCanvas({
|
|||||||
const handleCanvasMouseLeave = () => {
|
const handleCanvasMouseLeave = () => {
|
||||||
setMouseCoords(null);
|
setMouseCoords(null);
|
||||||
setPreviewPixel(null);
|
setPreviewPixel(null);
|
||||||
|
setIsPanning(false);
|
||||||
|
setLastPanPosition(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
// Mittlere Maustaste (Button 1)
|
||||||
|
if (e.button === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsPanning(true);
|
||||||
|
setLastPanPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (e: React.MouseEvent) => {
|
||||||
|
if (e.button === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsPanning(false);
|
||||||
|
setLastPanPosition(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMoveContainer = (e: React.MouseEvent) => {
|
||||||
|
if (isPanning && lastPanPosition && containerRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const deltaX = e.clientX - lastPanPosition.x;
|
||||||
|
const deltaY = e.clientY - lastPanPosition.y;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
container.scrollLeft -= deltaX;
|
||||||
|
container.scrollTop -= deltaY;
|
||||||
|
|
||||||
|
setLastPanPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomToPoint = (newZoom: number, mouseX?: number, mouseY?: number) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const clampedZoom = Math.max(0.1, Math.min(newZoom, 5));
|
||||||
|
if (Math.abs(clampedZoom - zoom) < 0.01) return;
|
||||||
|
|
||||||
|
const oldPixelSize = pixelSize;
|
||||||
|
const newPixelSize = Math.max(2, 8 * clampedZoom);
|
||||||
|
|
||||||
|
// Aktuelle Scroll-Position
|
||||||
|
const currentScrollLeft = container.scrollLeft;
|
||||||
|
const currentScrollTop = container.scrollTop;
|
||||||
|
|
||||||
|
let zoomPointX, zoomPointY;
|
||||||
|
|
||||||
|
if (mouseX !== undefined && mouseY !== undefined) {
|
||||||
|
// Zoomen an Mausposition
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const relativeMouseX = mouseX - containerRect.left;
|
||||||
|
const relativeMouseY = mouseY - containerRect.top;
|
||||||
|
|
||||||
|
// Canvas-Koordinaten der Mausposition
|
||||||
|
zoomPointX = (currentScrollLeft + relativeMouseX - 32) / oldPixelSize;
|
||||||
|
zoomPointY = (currentScrollTop + relativeMouseY - 32) / oldPixelSize;
|
||||||
|
|
||||||
|
setZoom(clampedZoom);
|
||||||
|
|
||||||
|
// Neue Scroll-Position berechnen, damit Mausposition gleich bleibt
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const newScrollLeft = zoomPointX * newPixelSize + 32 - relativeMouseX;
|
||||||
|
const newScrollTop = zoomPointY * newPixelSize + 32 - relativeMouseY;
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
left: Math.max(0, Math.min(newScrollLeft, container.scrollWidth - container.clientWidth)),
|
||||||
|
top: Math.max(0, Math.min(newScrollTop, container.scrollHeight - container.clientHeight)),
|
||||||
|
behavior: 'auto'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Zoomen im Center des Viewports
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const containerHeight = container.clientHeight;
|
||||||
|
|
||||||
|
zoomPointX = (currentScrollLeft + containerWidth / 2 - 32) / oldPixelSize;
|
||||||
|
zoomPointY = (currentScrollTop + containerHeight / 2 - 32) / oldPixelSize;
|
||||||
|
|
||||||
|
setZoom(clampedZoom);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const newScrollLeft = zoomPointX * newPixelSize + 32 - containerWidth / 2;
|
||||||
|
const newScrollTop = zoomPointY * newPixelSize + 32 - containerHeight / 2;
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
left: Math.max(0, Math.min(newScrollLeft, container.scrollWidth - container.clientWidth)),
|
||||||
|
top: Math.max(0, Math.min(newScrollTop, container.scrollHeight - container.clientHeight)),
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom(prev => Math.min(prev * 1.2, 3));
|
zoomToPoint(zoom * 1.2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
const handleZoomOut = () => {
|
||||||
setZoom(prev => Math.max(prev / 1.2, 0.5));
|
zoomToPoint(zoom / 1.2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetZoom = () => {
|
const handleResetZoom = () => {
|
||||||
setZoom(1);
|
zoomToPoint(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
@@ -178,23 +273,39 @@ export function OptimizedCanvas({
|
|||||||
const zoomFactor = 1.1;
|
const zoomFactor = 1.1;
|
||||||
const delta = e.deltaY;
|
const delta = e.deltaY;
|
||||||
|
|
||||||
|
let newZoom;
|
||||||
if (delta < 0) {
|
if (delta < 0) {
|
||||||
setZoom(prev => Math.min(prev * zoomFactor, 3));
|
newZoom = zoom * zoomFactor;
|
||||||
} else {
|
} else {
|
||||||
setZoom(prev => Math.max(prev / zoomFactor, 0.5));
|
newZoom = zoom / zoomFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
zoomToPoint(newZoom, e.clientX, e.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 relative bg-canvas-bg overflow-hidden">
|
<div className="flex-1 relative bg-canvas-bg overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full overflow-auto p-8 scroll-smooth canvas-container"
|
className={cn(
|
||||||
|
"w-full h-full overflow-auto p-8 canvas-container",
|
||||||
|
isPanning && "cursor-grabbing select-none"
|
||||||
|
)}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseMove={handleMouseMoveContainer}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setIsPanning(false);
|
||||||
|
setLastPanPosition(null);
|
||||||
|
}}
|
||||||
data-testid="canvas-container"
|
data-testid="canvas-container"
|
||||||
|
style={{
|
||||||
|
scrollBehavior: isPanning ? 'auto' : 'auto'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Coordinate System Container */}
|
{/* Coordinate System Container */}
|
||||||
<div className="relative inline-block canvas-zoom">
|
<div className="relative inline-block">
|
||||||
{/* Top X-axis coordinates */}
|
{/* Top X-axis coordinates */}
|
||||||
<div className="flex ml-8 mb-1">
|
<div className="flex ml-8 mb-1">
|
||||||
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
|
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
|
||||||
@@ -203,7 +314,7 @@ export function OptimizedCanvas({
|
|||||||
className="text-xs text-gray-400 text-center"
|
className="text-xs text-gray-400 text-center"
|
||||||
style={{
|
style={{
|
||||||
width: `${10 * pixelSize}px`,
|
width: `${10 * pixelSize}px`,
|
||||||
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
|
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i * 10}
|
{i * 10}
|
||||||
@@ -222,7 +333,7 @@ export function OptimizedCanvas({
|
|||||||
style={{
|
style={{
|
||||||
height: `${10 * pixelSize}px`,
|
height: `${10 * pixelSize}px`,
|
||||||
width: '24px',
|
width: '24px',
|
||||||
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
|
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i * 10}
|
{i * 10}
|
||||||
@@ -234,12 +345,13 @@ export function OptimizedCanvas({
|
|||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border border-gray-400 cursor-pointer",
|
"border border-gray-400",
|
||||||
cooldownActive && "cursor-not-allowed"
|
isPanning ? "cursor-grabbing" : cooldownActive ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
onMouseMove={handleCanvasMouseMove}
|
onMouseMove={handleCanvasMouseMove}
|
||||||
onMouseLeave={handleCanvasMouseLeave}
|
onMouseLeave={handleCanvasMouseLeave}
|
||||||
|
onContextMenu={(e) => e.preventDefault()} // Verhindert Rechtsklick-Menü
|
||||||
data-testid="pixel-canvas"
|
data-testid="pixel-canvas"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,49 +359,64 @@ export function OptimizedCanvas({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom Controls */}
|
{/* Zoom Controls */}
|
||||||
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/80 p-2 rounded shadow">
|
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/90 p-3 rounded-lg shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
|
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors"
|
||||||
data-testid="button-zoom-in"
|
data-testid="button-zoom-in"
|
||||||
|
disabled={zoom >= 5}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
|
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors"
|
||||||
data-testid="button-zoom-out"
|
data-testid="button-zoom-out"
|
||||||
|
disabled={zoom <= 0.1}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleResetZoom}
|
onClick={handleResetZoom}
|
||||||
className="px-2 py-1 bg-gray-500 text-white rounded text-xs hover:bg-gray-600"
|
className="px-2 py-1 bg-gray-500 text-white rounded text-xs font-semibold hover:bg-gray-600 transition-colors"
|
||||||
data-testid="button-zoom-reset"
|
data-testid="button-zoom-reset"
|
||||||
>
|
>
|
||||||
100%
|
100%
|
||||||
</button>
|
</button>
|
||||||
|
<div className="text-xs text-gray-600 text-center font-mono">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Display */}
|
{/* Info Display */}
|
||||||
<div className="absolute bottom-4 left-4 bg-white/80 p-3 rounded shadow text-sm">
|
<div className="absolute bottom-4 left-4 bg-white/90 p-3 rounded-lg shadow-lg text-sm">
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600 font-semibold">
|
||||||
Canvas: {canvasWidth}x{canvasHeight}
|
Canvas: {canvasWidth}×{canvasHeight}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
Zoom: {Math.round(zoom * 100)}%
|
Pixel: {pixelSize}px | Zoom: {Math.round(zoom * 100)}%
|
||||||
</div>
|
</div>
|
||||||
{mouseCoords && (
|
{mouseCoords && (
|
||||||
<div className="text-xs text-green-400 mt-1">
|
<div className="text-xs text-green-600 mt-2 font-mono">
|
||||||
Mouse: ({mouseCoords.x}, {mouseCoords.y})
|
Position: ({mouseCoords.x}, {mouseCoords.y})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewPixel && !cooldownActive && (
|
{previewPixel && !cooldownActive && (
|
||||||
<div className="text-xs text-blue-400 mt-1">
|
<div className="text-xs text-blue-600 mt-1">
|
||||||
Vorschau: {selectedColor}
|
Vorschau: {selectedColor}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{cooldownActive && (
|
||||||
|
<div className="text-xs text-red-500 mt-1 font-semibold">
|
||||||
|
Cooldown aktiv
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPanning && (
|
||||||
|
<div className="text-xs text-blue-500 mt-1 font-semibold">
|
||||||
|
Bewege Canvas (Mittlere Maustaste)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,31 +131,49 @@
|
|||||||
100% { background-position: 20px 20px; }
|
100% { background-position: 20px 20px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scrolling für den gesamten Container */
|
/* Canvas container optimiert für glattes Zoomen */
|
||||||
.scroll-smooth {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas zoom transitions */
|
|
||||||
.canvas-zoom {
|
|
||||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Geschmeidiges Mausrad-Scrolling */
|
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: auto; /* Für präzises Zoom-Verhalten */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optimierte Pixel-Hover-Effekte */
|
.canvas-container:not(:hover) {
|
||||||
.pixel {
|
scroll-behavior: smooth; /* Smooth nur wenn nicht gehovered */
|
||||||
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out, opacity 0.1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pixel:hover {
|
/* Zoom Controls */
|
||||||
transform: scale(1.1);
|
.canvas-container * {
|
||||||
z-index: 10;
|
image-rendering: pixelated;
|
||||||
position: relative;
|
image-rendering: -moz-crisp-edges;
|
||||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimierte Performance für große Canvas */
|
||||||
|
.canvas-container canvas {
|
||||||
|
will-change: transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions für UI Elemente */
|
||||||
|
.zoom-controls {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pixel-perfekte Rendering */
|
||||||
|
canvas {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verbesserte Hover-Effekte */
|
||||||
|
.canvas-container:hover {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Panel Styling */
|
||||||
|
.info-panel {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pixel-Vorschau */
|
/* Pixel-Vorschau */
|
||||||
|
|||||||
12
client/src/lib/canvas-api.ts
Normal file
12
client/src/lib/canvas-api.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
import { apiRequest } from "./queryClient";
|
||||||
|
|
||||||
|
export interface ExpandCanvasRequest {
|
||||||
|
canvasWidth: number;
|
||||||
|
canvasHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const expandCanvas = async (dimensions: ExpandCanvasRequest) => {
|
||||||
|
const response = await apiRequest("POST", "/api/config/expand", dimensions);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
@@ -29,7 +29,7 @@ export const COLORS = [
|
|||||||
"#ffb470", // Beige
|
"#ffb470", // Beige
|
||||||
"#000000", // Black
|
"#000000", // Black
|
||||||
"#515252", // Dark Gray
|
"#515252", // Dark Gray
|
||||||
"#898d90", // Gray
|
"#898989", // Gray
|
||||||
"#d4d7d9", // Light Gray
|
"#d4d7d9", // Light Gray
|
||||||
"#ffffff", // White
|
"#ffffff", // White
|
||||||
] as const;
|
] as const;
|
||||||
@@ -39,7 +39,7 @@ export const DEFAULT_SELECTED_COLOR = "#be0039"; // Official r/place red
|
|||||||
export function generateUserId(): string {
|
export function generateUserId(): string {
|
||||||
const stored = localStorage.getItem("r-place-user-id");
|
const stored = localStorage.getItem("r-place-user-id");
|
||||||
if (stored) return stored;
|
if (stored) return stored;
|
||||||
|
|
||||||
const newId = `User#${Math.floor(Math.random() * 10000)}`;
|
const newId = `User#${Math.floor(Math.random() * 10000)}`;
|
||||||
localStorage.setItem("r-place-user-id", newId);
|
localStorage.setItem("r-place-user-id", newId);
|
||||||
return newId;
|
return newId;
|
||||||
@@ -48,3 +48,24 @@ export function generateUserId(): string {
|
|||||||
export function getUsername(): string {
|
export function getUsername(): string {
|
||||||
return generateUserId();
|
return generateUserId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const API_BASE = "/api";
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
keycloakEnabled: boolean;
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthStatus(): Promise<AuthStatus> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/status`);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get auth status:", error);
|
||||||
|
return { authenticated: false, keycloakEnabled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
382
client/src/pages/admin.tsx
Normal file
382
client/src/pages/admin.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/lib/queryClient";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pixel, CanvasConfig } from "@shared/schema";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { AuthBanner } from "@/components/auth-banner";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [adminKey, setAdminKey] = useState("");
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [selectedPixelId, setSelectedPixelId] = useState("");
|
||||||
|
const [canvasWidth, setCanvasWidth] = useState(500);
|
||||||
|
const [canvasHeight, setCanvasHeight] = useState(200);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Auth check
|
||||||
|
const checkAuth = async (key: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("POST", "/api/admin/auth", { adminKey: key });
|
||||||
|
if (response.ok) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
localStorage.setItem("adminKey", key);
|
||||||
|
toast({ title: "Admin-Zugang gewährt" });
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid key");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Ungültiger Admin-Schlüssel",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check saved auth on load
|
||||||
|
useEffect(() => {
|
||||||
|
const savedKey = localStorage.getItem("adminKey");
|
||||||
|
if (savedKey) {
|
||||||
|
setAdminKey(savedKey);
|
||||||
|
checkAuth(savedKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const { data: pixels = [], refetch: refetchPixels } = useQuery<Pixel[]>({
|
||||||
|
queryKey: ['/api/pixels'],
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: config } = useQuery<CanvasConfig>({
|
||||||
|
queryKey: ['/api/config'],
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: adminStats } = useQuery({
|
||||||
|
queryKey: ['/api/admin/stats'],
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiRequest("GET", "/api/admin/stats", undefined, {
|
||||||
|
'X-Admin-Key': adminKey
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const deletePixelMutation = useMutation({
|
||||||
|
mutationFn: async (pixelId: string) => {
|
||||||
|
const response = await apiRequest("DELETE", `/api/admin/pixels/${pixelId}`, undefined, {
|
||||||
|
'X-Admin-Key': adminKey
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/admin/stats'] });
|
||||||
|
toast({ title: "Pixel gelöscht" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearCanvasMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const response = await apiRequest("DELETE", "/api/admin/canvas", undefined, {
|
||||||
|
'X-Admin-Key': adminKey
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/admin/stats'] });
|
||||||
|
toast({ title: "Canvas geleert" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandCanvasMutation = useMutation({
|
||||||
|
mutationFn: async ({ width, height }: { width: number; height: number }) => {
|
||||||
|
const response = await apiRequest("POST", "/api/config/expand", {
|
||||||
|
canvasWidth: width,
|
||||||
|
canvasHeight: height
|
||||||
|
}, {
|
||||||
|
'X-Admin-Key': adminKey
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/config'] });
|
||||||
|
toast({ title: "Canvas erweitert" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportCanvasMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const response = await apiRequest("POST", "/api/admin/export", undefined, {
|
||||||
|
'X-Admin-Key': adminKey
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({ title: "Canvas exportiert", description: `Datei: ${data.filename}` });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas-bg text-white flex items-center justify-center">
|
||||||
|
<Card className="w-96 bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Admin-Zugang</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Admin-Schlüssel eingeben"
|
||||||
|
value={adminKey}
|
||||||
|
onChange={(e) => setAdminKey(e.target.value)}
|
||||||
|
className="bg-panel-hover border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => checkAuth(adminKey)}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!adminKey}
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas-bg text-white">
|
||||||
|
<AuthBanner />
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Admin-Panel</h1>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
localStorage.removeItem("adminKey");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="overview" className="space-y-6">
|
||||||
|
<TabsList className="bg-panel-bg">
|
||||||
|
<TabsTrigger value="overview">Übersicht</TabsTrigger>
|
||||||
|
<TabsTrigger value="moderation">Moderation</TabsTrigger>
|
||||||
|
<TabsTrigger value="canvas">Canvas-Verwaltung</TabsTrigger>
|
||||||
|
<TabsTrigger value="export">Export</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Statistiken</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-white">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Gesamte Pixel:</span>
|
||||||
|
<Badge>{adminStats?.totalPixels || 0}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Einzigartige Nutzer:</span>
|
||||||
|
<Badge>{adminStats?.uniqueUsers || 0}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Canvas-Größe:</span>
|
||||||
|
<Badge>{config?.canvasWidth}×{config?.canvasHeight}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Letzte Aktivität</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-white">
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{pixels.slice(0, 5).map((pixel) => (
|
||||||
|
<div key={pixel.id} className="text-sm">
|
||||||
|
<span className="text-muted">{pixel.username}</span> -
|
||||||
|
<span className="ml-1">({pixel.x}, {pixel.y})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">System</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-white">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Cooldown:</span>
|
||||||
|
<Badge>{config?.defaultCooldown}s</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Auto-Events:</span>
|
||||||
|
<Badge variant={config?.enableAutomaticEvents ? "default" : "secondary"}>
|
||||||
|
{config?.enableAutomaticEvents ? "An" : "Aus"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="moderation">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Pixel-Moderation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Pixel-ID eingeben"
|
||||||
|
value={selectedPixelId}
|
||||||
|
onChange={(e) => setSelectedPixelId(e.target.value)}
|
||||||
|
className="bg-panel-hover border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => deletePixelMutation.mutate(selectedPixelId)}
|
||||||
|
disabled={!selectedPixelId}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Pixel löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-600 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => clearCanvasMutation.mutate()}
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Gesamtes Canvas leeren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Pixel-Liste</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-2">
|
||||||
|
{pixels.slice().reverse().slice(0, 20).map((pixel) => (
|
||||||
|
<div key={pixel.id} className="flex items-center justify-between p-2 bg-panel-hover rounded">
|
||||||
|
<div className="text-white text-sm">
|
||||||
|
<span className="font-mono">{pixel.id.slice(0, 8)}</span> -
|
||||||
|
<span className="ml-2">{pixel.username}</span> -
|
||||||
|
<span className="ml-2">({pixel.x}, {pixel.y})</span> -
|
||||||
|
<span
|
||||||
|
className="ml-2 inline-block w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: pixel.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deletePixelMutation.mutate(pixel.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="canvas">
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Canvas-Verwaltung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-white text-sm">Breite</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={canvasWidth}
|
||||||
|
onChange={(e) => setCanvasWidth(Number(e.target.value))}
|
||||||
|
className="bg-panel-hover border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-white text-sm">Höhe</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={canvasHeight}
|
||||||
|
onChange={(e) => setCanvasHeight(Number(e.target.value))}
|
||||||
|
className="bg-panel-hover border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => expandCanvasMutation.mutate({
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight
|
||||||
|
})}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Canvas erweitern
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Aktuelle Größe: {config?.canvasWidth}×{config?.canvasHeight}
|
||||||
|
<br />
|
||||||
|
Hinweis: Canvas kann nur erweitert, nicht verkleinert werden.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="export">
|
||||||
|
<Card className="bg-panel-bg border-gray-700">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Export-Verwaltung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => exportCanvasMutation.mutate()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Manueller SVG-Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Automatische Exports erfolgen alle {config?.exportInterval || 60} Sekunden.
|
||||||
|
Dateien werden im exports/ Verzeichnis gespeichert.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { DEFAULT_SELECTED_COLOR, generateUserId, getUsername } from "@/lib/config";
|
import { DEFAULT_SELECTED_COLOR, generateUserId, getUsername } from "@/lib/config";
|
||||||
import { Pixel, CanvasConfig, InsertPixel, WSMessage } from "@shared/schema";
|
import { Pixel, CanvasConfig, InsertPixel, WSMessage } from "@shared/schema";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { AuthBanner } from "@/components/auth-banner";
|
||||||
|
|
||||||
export default function CanvasPage() {
|
export default function CanvasPage() {
|
||||||
const [selectedColor, setSelectedColor] = useState(DEFAULT_SELECTED_COLOR);
|
const [selectedColor, setSelectedColor] = useState(DEFAULT_SELECTED_COLOR);
|
||||||
@@ -41,15 +42,17 @@ export default function CanvasPage() {
|
|||||||
// Invalidate pixels cache to refetch
|
// Invalidate pixels cache to refetch
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
|
||||||
|
|
||||||
//toast({
|
|
||||||
// title: "Pixel placed",
|
|
||||||
// description: `${message.data.username} placed a pixel at (${message.data.x}, ${message.data.y})`,
|
|
||||||
// });
|
|
||||||
//break;
|
|
||||||
|
|
||||||
|
|
||||||
|
// Aktualisiere Cooldown nach jedem Pixel-Placement (auch von anderen Nutzern)
|
||||||
|
// um sicherzustellen, dass lokaler State mit Server synchron ist
|
||||||
|
if (userId) {
|
||||||
|
fetch(`/api/cooldown/${userId}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setCooldownSeconds(data.remainingSeconds))
|
||||||
|
.catch(error => console.error("Failed to sync cooldown:", error));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "cooldown_update":
|
case "cooldown_update":
|
||||||
if (message.data.userId === userId) {
|
if (message.data.userId === userId) {
|
||||||
setCooldownSeconds(message.data.remainingSeconds);
|
setCooldownSeconds(message.data.remainingSeconds);
|
||||||
@@ -66,12 +69,21 @@ export default function CanvasPage() {
|
|||||||
const response = await apiRequest("POST", "/api/pixels", pixel);
|
const response = await apiRequest("POST", "/api/pixels", pixel);
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
|
||||||
// Start cooldown countdown
|
|
||||||
if (config && !config.enableAutomaticEvents) {
|
// Hole den aktuellen Cooldown-Status vom Server
|
||||||
setCooldownSeconds(config.defaultCooldown);
|
try {
|
||||||
|
const cooldownResponse = await fetch(`/api/cooldown/${userId}`);
|
||||||
|
const cooldownData = await cooldownResponse.json();
|
||||||
|
setCooldownSeconds(cooldownData.remainingSeconds);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch cooldown after placement:", error);
|
||||||
|
// Fallback: Setze Standard-Cooldown wenn Server-Abfrage fehlschlägt
|
||||||
|
if (config && !config.enableAutomaticEvents) {
|
||||||
|
setCooldownSeconds(config.defaultCooldown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -116,15 +128,19 @@ export default function CanvasPage() {
|
|||||||
console.error("Failed to fetch cooldown:", error);
|
console.error("Failed to fetch cooldown:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userId) {
|
if (userId && config) {
|
||||||
fetchCooldown();
|
fetchCooldown();
|
||||||
|
|
||||||
|
// Aktualisiere Cooldown regelmäßig für den Fall, dass er anderswo gesetzt wurde
|
||||||
|
const interval = setInterval(fetchCooldown, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, config]);
|
||||||
|
|
||||||
const handlePixelClick = (x: number, y: number) => {
|
const handlePixelClick = (x: number, y: number) => {
|
||||||
if (cooldownSeconds > 0) return;
|
if (cooldownSeconds > 0) return;
|
||||||
|
|
||||||
placePixelMutation.mutate({
|
placePixelMutation.mutate({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -146,6 +162,7 @@ export default function CanvasPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-canvas-bg text-white">
|
<div className="h-screen flex flex-col bg-canvas-bg text-white">
|
||||||
|
<AuthBanner />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-panel-bg border-b border-gray-700 px-4 py-3 flex items-center justify-between">
|
<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">
|
<div className="flex items-center space-x-4">
|
||||||
@@ -156,11 +173,16 @@ export default function CanvasPage() {
|
|||||||
<span>users online</span>
|
<span>users online</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* Admin Link */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.location.href = '/admin'}
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="flex items-center space-x-2 px-3 py-2 bg-panel-hover rounded-lg">
|
<div className="flex items-center space-x-2 px-3 py-2 bg-panel-hover rounded-lg">
|
||||||
@@ -180,7 +202,7 @@ export default function CanvasPage() {
|
|||||||
onPixelClick={handlePixelClick}
|
onPixelClick={handlePixelClick}
|
||||||
cooldownActive={cooldownSeconds > 0}
|
cooldownActive={cooldownSeconds > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ColorPalette
|
<ColorPalette
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onColorSelect={setSelectedColor}
|
onColorSelect={setSelectedColor}
|
||||||
@@ -190,4 +212,4 @@ export default function CanvasPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
18
config.cfg
18
config.cfg
@@ -2,11 +2,11 @@
|
|||||||
# Ändere diese Werte um die Canvas-Einstellungen anzupassen
|
# Ändere diese Werte um die Canvas-Einstellungen anzupassen
|
||||||
|
|
||||||
# Canvas Dimensionen
|
# Canvas Dimensionen
|
||||||
CANVAS_WIDTH=100
|
CANVAS_WIDTH=500
|
||||||
CANVAS_HEIGHT=100
|
CANVAS_HEIGHT=200
|
||||||
|
|
||||||
# Cooldown Einstellungen (in Sekunden)
|
# Cooldown Einstellungen (in Sekunden)
|
||||||
DEFAULT_COOLDOWN=5
|
DEFAULT_COOLDOWN=10
|
||||||
|
|
||||||
# Automatische Events (true/false)
|
# Automatische Events (true/false)
|
||||||
# Wenn aktiviert, gibt es keine Cooldowns
|
# Wenn aktiviert, gibt es keine Cooldowns
|
||||||
@@ -14,10 +14,16 @@ ENABLE_AUTOMATIC_EVENTS=false
|
|||||||
|
|
||||||
# Event Einstellungen
|
# Event Einstellungen
|
||||||
EVENT_DURATION_MINUTES=30
|
EVENT_DURATION_MINUTES=30
|
||||||
EVENT_INTERVAL_HOURS=6
|
EVENT_INTERVAL_HOURS=1
|
||||||
|
|
||||||
|
|
||||||
# Grid-Funktionalität wurde entfernt
|
|
||||||
|
|
||||||
# Export Einstellungen
|
# Export Einstellungen
|
||||||
AUTO_EXPORT_INTERVAL_SECONDS=60
|
AUTO_EXPORT_INTERVAL_SECONDS=60
|
||||||
EXPORT_PATH=./exports/
|
EXPORT_PATH=./exports/
|
||||||
|
|
||||||
|
# Keycloak Einstellungen
|
||||||
|
ENABLE_KEYCLOAK=false
|
||||||
|
KEYCLOAK_REALM=rplace
|
||||||
|
KEYCLOAK_AUTH_URL=http://localhost:8080/auth
|
||||||
|
KEYCLOAK_CLIENT_ID=rplace-client
|
||||||
1245
package-lock.json
generated
1245
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,8 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -51,9 +53,10 @@
|
|||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.2",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"keycloak-connect": "^26.1.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
|
|||||||
@@ -11,76 +11,61 @@ interface Config {
|
|||||||
|
|
||||||
autoExportIntervalSeconds: number;
|
autoExportIntervalSeconds: number;
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
|
adminKey: string;
|
||||||
|
|
||||||
|
enableKeycloak: boolean;
|
||||||
|
keycloakRealm: string;
|
||||||
|
keycloakAuthUrl: string;
|
||||||
|
keycloakClientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseConfigFile(): Config {
|
function parseConfigFile(): Config {
|
||||||
try {
|
try {
|
||||||
const configPath = join(process.cwd(), "config.cfg");
|
const configPath = join(process.cwd(), "config.cfg");
|
||||||
const configContent = readFileSync(configPath, "utf-8");
|
const configContent = readFileSync(configPath, "utf-8");
|
||||||
|
|
||||||
const config: Partial<Config> = {};
|
const configMap = new Map<string, string>();
|
||||||
|
|
||||||
configContent.split("\n").forEach(line => {
|
configContent.split("\n").forEach(line => {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line.startsWith("#") || !line.includes("=")) return;
|
if (line.startsWith("#") || !line.includes("=")) return;
|
||||||
|
|
||||||
const [key, value] = line.split("=");
|
const [key, value] = line.split("=");
|
||||||
const trimmedKey = key.trim();
|
configMap.set(key.trim(), value.trim());
|
||||||
const trimmedValue = value.trim();
|
|
||||||
|
|
||||||
switch (trimmedKey) {
|
|
||||||
case "CANVAS_WIDTH":
|
|
||||||
config.canvasWidth = parseInt(trimmedValue);
|
|
||||||
break;
|
|
||||||
case "CANVAS_HEIGHT":
|
|
||||||
config.canvasHeight = parseInt(trimmedValue);
|
|
||||||
break;
|
|
||||||
case "DEFAULT_COOLDOWN":
|
|
||||||
config.defaultCooldown = parseInt(trimmedValue);
|
|
||||||
break;
|
|
||||||
case "ENABLE_AUTOMATIC_EVENTS":
|
|
||||||
config.enableAutomaticEvents = trimmedValue.toLowerCase() === "true";
|
|
||||||
break;
|
|
||||||
case "EVENT_DURATION_MINUTES":
|
|
||||||
config.eventDurationMinutes = parseInt(trimmedValue);
|
|
||||||
break;
|
|
||||||
case "EVENT_INTERVAL_HOURS":
|
|
||||||
config.eventIntervalHours = parseInt(trimmedValue);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "AUTO_EXPORT_INTERVAL_SECONDS":
|
|
||||||
config.autoExportIntervalSeconds = parseInt(trimmedValue);
|
|
||||||
break;
|
|
||||||
case "EXPORT_PATH":
|
|
||||||
config.exportPath = trimmedValue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set defaults for missing values
|
|
||||||
return {
|
|
||||||
canvasWidth: config.canvasWidth || 100,
|
|
||||||
canvasHeight: config.canvasHeight || 100,
|
|
||||||
defaultCooldown: config.defaultCooldown || 5,
|
|
||||||
enableAutomaticEvents: config.enableAutomaticEvents || false,
|
|
||||||
eventDurationMinutes: config.eventDurationMinutes || 30,
|
|
||||||
eventIntervalHours: config.eventIntervalHours || 6,
|
|
||||||
|
|
||||||
autoExportIntervalSeconds: config.autoExportIntervalSeconds || 60,
|
|
||||||
exportPath: config.exportPath || "./exports/",
|
return {
|
||||||
|
canvasWidth: parseInt(configMap.get('CANVAS_WIDTH') || '500'),
|
||||||
|
canvasHeight: parseInt(configMap.get('CANVAS_HEIGHT') || '200'),
|
||||||
|
defaultCooldown: parseInt(configMap.get('DEFAULT_COOLDOWN') || '10'),
|
||||||
|
enableAutomaticEvents: configMap.get('ENABLE_AUTOMATIC_EVENTS') === 'true',
|
||||||
|
eventDurationMinutes: parseInt(configMap.get('EVENT_DURATION_MINUTES') || '30'),
|
||||||
|
eventIntervalHours: parseInt(configMap.get('EVENT_INTERVAL_HOURS') || '1'),
|
||||||
|
autoExportIntervalSeconds: parseInt(configMap.get('AUTO_EXPORT_INTERVAL_SECONDS') || '60'),
|
||||||
|
exportPath: configMap.get('EXPORT_PATH') || './exports/',
|
||||||
|
adminKey: configMap.get('ADMIN_KEY') || 'admin123',
|
||||||
|
enableKeycloak: configMap.get('ENABLE_KEYCLOAK') === 'true',
|
||||||
|
keycloakRealm: configMap.get('KEYCLOAK_REALM') || 'rplace',
|
||||||
|
keycloakAuthUrl: configMap.get('KEYCLOAK_AUTH_URL') || 'http://localhost:8080/auth',
|
||||||
|
keycloakClientId: configMap.get('KEYCLOAK_CLIENT_ID') || 'rplace-client',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading config file, using defaults:", error);
|
console.error("Error reading config file, using defaults:", error);
|
||||||
return {
|
return {
|
||||||
canvasWidth: 100,
|
canvasWidth: 500,
|
||||||
canvasHeight: 100,
|
canvasHeight: 200,
|
||||||
defaultCooldown: 5,
|
defaultCooldown: 10,
|
||||||
enableAutomaticEvents: false,
|
enableAutomaticEvents: false,
|
||||||
eventDurationMinutes: 30,
|
eventDurationMinutes: 30,
|
||||||
eventIntervalHours: 6,
|
eventIntervalHours: 1,
|
||||||
|
|
||||||
autoExportIntervalSeconds: 60,
|
autoExportIntervalSeconds: 60,
|
||||||
exportPath: "./exports/",
|
exportPath: "./exports/",
|
||||||
|
adminKey: 'admin123',
|
||||||
|
|
||||||
|
enableKeycloak: false,
|
||||||
|
keycloakRealm: "rplace",
|
||||||
|
keycloakAuthUrl: "http://localhost:8080/auth",
|
||||||
|
keycloakClientId: "rplace-client",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import express, { type Request, Response, NextFunction } from "express";
|
import express, { type Request, Response, NextFunction } from "express";
|
||||||
import { registerRoutes } from "./routes";
|
import { registerRoutes } from "./routes";
|
||||||
import { setupVite, serveStatic, log } from "./vite";
|
import { setupVite, serveStatic, log } from "./vite";
|
||||||
|
import { setupKeycloak } from "./keycloak";
|
||||||
|
import { config } from "./config";
|
||||||
|
import { storage } from "./storage";
|
||||||
|
import { CanvasExporter } from "./export";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
// Keycloak Setup
|
||||||
|
let keycloak: any = null;
|
||||||
|
if (config.enableKeycloak) {
|
||||||
|
// Set environment variables for Keycloak
|
||||||
|
process.env.KEYCLOAK_REALM = config.keycloakRealm;
|
||||||
|
process.env.KEYCLOAK_AUTH_URL = config.keycloakAuthUrl;
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID = config.keycloakClientId;
|
||||||
|
|
||||||
|
keycloak = setupKeycloak(app);
|
||||||
|
log("Keycloak authentication enabled");
|
||||||
|
} else {
|
||||||
|
log("Keycloak authentication disabled");
|
||||||
|
}
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const path = req.path;
|
const path = req.path;
|
||||||
@@ -56,11 +74,47 @@ app.use((req, res, next) => {
|
|||||||
serveStatic(app);
|
serveStatic(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aktualisiere Canvas-Konfiguration beim Start falls sich config.cfg geändert hat
|
||||||
|
try {
|
||||||
|
const currentConfig = await storage.getCanvasConfig();
|
||||||
|
const configChanged =
|
||||||
|
currentConfig.canvasWidth !== config.canvasWidth ||
|
||||||
|
currentConfig.canvasHeight !== config.canvasHeight ||
|
||||||
|
currentConfig.defaultCooldown !== config.defaultCooldown ||
|
||||||
|
currentConfig.enableAutomaticEvents !== config.enableAutomaticEvents ||
|
||||||
|
currentConfig.eventDuration !== config.eventDurationMinutes ||
|
||||||
|
currentConfig.eventInterval !== config.eventIntervalHours;
|
||||||
|
|
||||||
|
if (configChanged) {
|
||||||
|
console.log(`${formatTime()} [express] Aktualisiere Canvas-Konfiguration aus config.cfg`);
|
||||||
|
|
||||||
|
// Für SQLite Storage: Verwende expandCanvas wenn Canvas vergrößert wird
|
||||||
|
if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function' &&
|
||||||
|
(config.canvasWidth > currentConfig.canvasWidth || config.canvasHeight > currentConfig.canvasHeight)) {
|
||||||
|
await (storage as any).expandCanvas(config.canvasWidth, config.canvasHeight);
|
||||||
|
} else {
|
||||||
|
await storage.updateCanvasConfig({
|
||||||
|
canvasWidth: Math.max(currentConfig.canvasWidth, config.canvasWidth), // Erlaube nur Erweiterung
|
||||||
|
canvasHeight: Math.max(currentConfig.canvasHeight, config.canvasHeight), // Erlaube nur Erweiterung
|
||||||
|
defaultCooldown: config.defaultCooldown,
|
||||||
|
enableAutomaticEvents: config.enableAutomaticEvents,
|
||||||
|
eventDuration: config.eventDurationMinutes,
|
||||||
|
eventInterval: config.eventIntervalHours
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`${formatTime()} [express] Canvas-Konfiguration aktualisiert`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${formatTime()} [express] Fehler beim Aktualisieren der Canvas-Konfiguration:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas exporter wird bereits in routes.ts initialisiert
|
||||||
|
|
||||||
// ALWAYS serve the app on the port specified in the environment variable PORT
|
// ALWAYS serve the app on the port specified in the environment variable PORT
|
||||||
// Other ports are firewalled. Default to 5000 if not specified.
|
// Other ports are firewalled. Default to 5000 if not specified.
|
||||||
// this serves both the API and the client.
|
// this serves both the API and the client.
|
||||||
// It is the only port that is not firewalled.
|
// It is the only port that is not firewalled.
|
||||||
const port = parseInt(process.env.PORT || '5000', 10);
|
const port = parseInt(process.env.PORT || '5001', 10);
|
||||||
server.listen({
|
server.listen({
|
||||||
port,
|
port,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
@@ -69,3 +123,11 @@ app.use((req, res, next) => {
|
|||||||
log(`serving on port ${port}`);
|
log(`serving on port ${port}`);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function formatTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
return `[${hours}:${minutes}:${seconds}]`;
|
||||||
|
}
|
||||||
56
server/keycloak.ts
Normal file
56
server/keycloak.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
import Keycloak from 'keycloak-connect';
|
||||||
|
import session from 'express-session';
|
||||||
|
import { type Express } from 'express';
|
||||||
|
|
||||||
|
interface KeycloakConfig {
|
||||||
|
realm: string;
|
||||||
|
'auth-server-url': string;
|
||||||
|
'ssl-required': string;
|
||||||
|
resource: string;
|
||||||
|
'public-client': boolean;
|
||||||
|
'confidential-port': number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keycloak Konfiguration aus Umgebungsvariablen oder Standard
|
||||||
|
const keycloakConfig: KeycloakConfig = {
|
||||||
|
realm: process.env.KEYCLOAK_REALM || 'rplace',
|
||||||
|
'auth-server-url': process.env.KEYCLOAK_AUTH_URL || 'http://localhost:8080/auth',
|
||||||
|
'ssl-required': 'external',
|
||||||
|
resource: process.env.KEYCLOAK_CLIENT_ID || 'rplace-client',
|
||||||
|
'public-client': true,
|
||||||
|
'confidential-port': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session Store für Keycloak
|
||||||
|
const memoryStore = session.MemoryStore ? new session.MemoryStore() : undefined;
|
||||||
|
|
||||||
|
export function setupKeycloak(app: Express) {
|
||||||
|
// Session Middleware
|
||||||
|
const sessionConfig = {
|
||||||
|
secret: process.env.SESSION_SECRET || 'rplace-secret-key',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: true,
|
||||||
|
store: memoryStore,
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 Stunden
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(session(sessionConfig));
|
||||||
|
|
||||||
|
// Keycloak initialisieren
|
||||||
|
const keycloak = new Keycloak({ store: memoryStore }, keycloakConfig);
|
||||||
|
|
||||||
|
// Keycloak Middleware
|
||||||
|
app.use(keycloak.middleware({
|
||||||
|
logout: '/logout',
|
||||||
|
admin: '/',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return keycloak;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { keycloakConfig };
|
||||||
222
server/routes.ts
222
server/routes.ts
@@ -1,9 +1,53 @@
|
|||||||
import type { Express } from "express";
|
import type { Express, Request, Response, NextFunction } from "express";
|
||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema";
|
import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema";
|
||||||
import { CanvasExporter } from "./export";
|
import { CanvasExporter } from "./export";
|
||||||
|
import { config } from "./config";
|
||||||
|
|
||||||
|
// Admin authentication middleware
|
||||||
|
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const adminKey = req.headers['x-admin-key'] || req.body.adminKey;
|
||||||
|
|
||||||
|
if (!adminKey || adminKey !== config.adminKey) {
|
||||||
|
return res.status(401).json({ message: "Admin access required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication middleware
|
||||||
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!config.enableKeycloak) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authenticated via Keycloak
|
||||||
|
if (req.kauth && req.kauth.grant) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ message: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info from Keycloak token
|
||||||
|
function getUserFromToken(req: Request): { userId: string; username: string } {
|
||||||
|
if (!config.enableKeycloak || !req.kauth?.grant?.access_token) {
|
||||||
|
return {
|
||||||
|
userId: "User",
|
||||||
|
username: "Anonymous"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = req.kauth.grant.access_token;
|
||||||
|
const content = token.content;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: content.sub || content.preferred_username || "User",
|
||||||
|
username: content.preferred_username || content.name || "User"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
@@ -15,6 +59,38 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const exporter = new CanvasExporter(storage);
|
const exporter = new CanvasExporter(storage);
|
||||||
exporter.startAutoExport();
|
exporter.startAutoExport();
|
||||||
|
|
||||||
|
// Authentication Routes
|
||||||
|
app.get("/api/auth/status", (req, res) => {
|
||||||
|
if (!config.enableKeycloak) {
|
||||||
|
return res.json({ authenticated: false, keycloakEnabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = req.kauth && req.kauth.grant;
|
||||||
|
const user = isAuthenticated ? getUserFromToken(req) : null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
authenticated: isAuthenticated,
|
||||||
|
keycloakEnabled: true,
|
||||||
|
user: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login redirect
|
||||||
|
app.get("/login", (req, res) => {
|
||||||
|
if (config.enableKeycloak && req.kauth) {
|
||||||
|
return req.kauth.login(req, res);
|
||||||
|
}
|
||||||
|
res.redirect("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
app.get("/logout", (req, res) => {
|
||||||
|
if (config.enableKeycloak && req.kauth) {
|
||||||
|
return req.kauth.logout(req, res);
|
||||||
|
}
|
||||||
|
res.redirect("/");
|
||||||
|
});
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.get("/api/pixels", async (req, res) => {
|
app.get("/api/pixels", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -34,12 +110,59 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Canvas-Erweiterungs-Endpoint
|
||||||
|
app.post("/api/config/expand", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { canvasWidth, canvasHeight } = req.body;
|
||||||
|
|
||||||
|
if (!canvasWidth || !canvasHeight || canvasWidth < 1 || canvasHeight < 1) {
|
||||||
|
return res.status(400).json({ message: "Invalid canvas dimensions" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentConfig = await storage.getCanvasConfig();
|
||||||
|
|
||||||
|
// Erlaube nur Erweiterung, nicht Verkleinerung
|
||||||
|
if (canvasWidth < currentConfig.canvasWidth || canvasHeight < currentConfig.canvasHeight) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Canvas kann nur erweitert werden, nicht verkleinert",
|
||||||
|
current: { width: currentConfig.canvasWidth, height: currentConfig.canvasHeight }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für SQLite Storage: Verwende spezielle expandCanvas Methode
|
||||||
|
if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function') {
|
||||||
|
await (storage as any).expandCanvas(canvasWidth, canvasHeight);
|
||||||
|
} else {
|
||||||
|
// Fallback für Memory Storage
|
||||||
|
await storage.updateCanvasConfig({
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
defaultCooldown: currentConfig.defaultCooldown,
|
||||||
|
enableAutomaticEvents: currentConfig.enableAutomaticEvents,
|
||||||
|
eventDuration: currentConfig.eventDuration,
|
||||||
|
eventInterval: currentConfig.eventInterval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedConfig = await storage.getCanvasConfig();
|
||||||
|
res.json(updatedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to expand canvas:", error);
|
||||||
|
res.status(500).json({ message: "Failed to expand canvas" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Config is now read-only from file
|
// Config is now read-only from file
|
||||||
// Remove the POST endpoint for config updates
|
// Remove the POST endpoint for config updates
|
||||||
|
|
||||||
app.post("/api/pixels", async (req, res) => {
|
app.post("/api/pixels", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pixelData = insertPixelSchema.parse(req.body);
|
const userInfo = getUserFromToken(req);
|
||||||
|
const pixelData = insertPixelSchema.parse({
|
||||||
|
...req.body,
|
||||||
|
userId: userInfo.userId,
|
||||||
|
username: userInfo.username
|
||||||
|
});
|
||||||
const config = await storage.getCanvasConfig();
|
const config = await storage.getCanvasConfig();
|
||||||
|
|
||||||
// Validate coordinates
|
// Validate coordinates
|
||||||
@@ -50,15 +173,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
|
|
||||||
// Check cooldown unless events are enabled
|
// Check cooldown unless events are enabled
|
||||||
if (!config.enableAutomaticEvents) {
|
if (!config.enableAutomaticEvents) {
|
||||||
const cooldown = await storage.getUserCooldown(pixelData.userId);
|
const cooldown = await storage.getUserCooldown(userInfo.userId);
|
||||||
if (cooldown && cooldown.cooldownEnds > new Date()) {
|
const now = new Date();
|
||||||
return res.status(429).json({ message: "Cooldown active" });
|
|
||||||
|
if (cooldown && cooldown.cooldownEnds > now) {
|
||||||
|
const remaining = Math.ceil((cooldown.cooldownEnds.getTime() - now.getTime()) / 1000);
|
||||||
|
return res.status(429).json({
|
||||||
|
message: "Cooldown active",
|
||||||
|
remainingSeconds: remaining
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set new cooldown
|
// Set new cooldown - immer setzen, auch wenn kein vorheriger existierte
|
||||||
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
|
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
|
||||||
await storage.setUserCooldown({
|
await storage.setUserCooldown({
|
||||||
userId: pixelData.userId,
|
userId: userInfo.userId,
|
||||||
cooldownEnds: cooldownEnd,
|
cooldownEnds: cooldownEnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,6 +285,83 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin Routes
|
||||||
|
app.post("/api/admin/auth", (req, res) => {
|
||||||
|
const { adminKey } = req.body;
|
||||||
|
|
||||||
|
if (adminKey === config.adminKey) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ message: "Invalid admin key" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/stats", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pixels = await storage.getAllPixels();
|
||||||
|
const uniqueUsers = new Set(pixels.map(p => p.userId)).size;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalPixels: pixels.length,
|
||||||
|
uniqueUsers,
|
||||||
|
lastActivity: pixels.length > 0 ? pixels[pixels.length - 1].createdAt : null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to fetch admin stats" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/pixels/:id", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if ('deletePixel' in storage && typeof storage.deletePixel === 'function') {
|
||||||
|
await (storage as any).deletePixel(id);
|
||||||
|
|
||||||
|
// Broadcast pixel deletion
|
||||||
|
broadcast({
|
||||||
|
type: "pixel_deleted",
|
||||||
|
data: { pixelId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(501).json({ message: "Pixel deletion not supported by current storage" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to delete pixel" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/canvas", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if ('clearCanvas' in storage && typeof storage.clearCanvas === 'function') {
|
||||||
|
await (storage as any).clearCanvas();
|
||||||
|
|
||||||
|
// Broadcast canvas clear
|
||||||
|
broadcast({
|
||||||
|
type: "canvas_cleared",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(501).json({ message: "Canvas clearing not supported by current storage" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to clear canvas" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/export", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filename = await exporter.exportCanvas();
|
||||||
|
res.json({ filename, success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: "Failed to export canvas" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Keep connections alive with ping/pong
|
// Keep connections alive with ping/pong
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
connectedUsers.forEach(ws => {
|
connectedUsers.forEach(ws => {
|
||||||
|
|||||||
220
server/sqlite-storage.ts
Normal file
220
server/sqlite-storage.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { type Pixel, type InsertPixel, type CanvasConfig, type InsertCanvasConfig, type UserCooldown, type InsertUserCooldown } from "@shared/schema";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { config } from "./config";
|
||||||
|
import { IStorage } from "./storage";
|
||||||
|
|
||||||
|
export class SQLiteStorage implements IStorage {
|
||||||
|
private db: Database.Database;
|
||||||
|
|
||||||
|
constructor(dbPath: string = ':memory:') {
|
||||||
|
this.db = new Database(dbPath);
|
||||||
|
this.initTables();
|
||||||
|
this.initDefaultConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initTables() {
|
||||||
|
// Pixels table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pixels (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Canvas config table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS canvas_config (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
|
||||||
|
canvasWidth INTEGER NOT NULL DEFAULT 100,
|
||||||
|
canvasHeight INTEGER NOT NULL DEFAULT 100,
|
||||||
|
defaultCooldown INTEGER NOT NULL DEFAULT 5,
|
||||||
|
enableAutomaticEvents BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
eventDuration INTEGER NOT NULL DEFAULT 30,
|
||||||
|
eventInterval INTEGER NOT NULL DEFAULT 6,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User cooldowns table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_cooldowns (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
|
||||||
|
userId TEXT NOT NULL UNIQUE,
|
||||||
|
lastPlacement DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
cooldownEnds DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pixels_xy ON pixels(x, y)`);
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pixels_created ON pixels(createdAt DESC)`);
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_cooldowns_user ON user_cooldowns(userId)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initDefaultConfig() {
|
||||||
|
const existingConfig = this.db.prepare('SELECT * FROM canvas_config LIMIT 1').get();
|
||||||
|
if (!existingConfig) {
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO canvas_config (canvasWidth, canvasHeight, defaultCooldown, enableAutomaticEvents, eventDuration, eventInterval)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
config.canvasWidth,
|
||||||
|
config.canvasHeight,
|
||||||
|
config.defaultCooldown,
|
||||||
|
config.enableAutomaticEvents ? 1 : 0,
|
||||||
|
config.eventDurationMinutes,
|
||||||
|
config.eventIntervalHours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPixel(x: number, y: number): Promise<Pixel | undefined> {
|
||||||
|
const row = this.db.prepare('SELECT * FROM pixels WHERE x = ? AND y = ? ORDER BY createdAt DESC LIMIT 1').get(x, y) as any;
|
||||||
|
if (!row) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
createdAt: new Date(row.createdAt),
|
||||||
|
enableAutomaticEvents: Boolean(row.enableAutomaticEvents)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPixels(): Promise<Pixel[]> {
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT p1.* FROM pixels p1
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT x, y, MAX(createdAt) as maxCreated
|
||||||
|
FROM pixels
|
||||||
|
GROUP BY x, y
|
||||||
|
) p2 ON p1.x = p2.x AND p1.y = p2.y AND p1.createdAt = p2.maxCreated
|
||||||
|
`).all() as any[];
|
||||||
|
|
||||||
|
return rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
createdAt: new Date(row.createdAt)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async placePixel(insertPixel: InsertPixel): Promise<Pixel> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO pixels (id, x, y, color, userId, username, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, insertPixel.x, insertPixel.y, insertPixel.color, insertPixel.userId, insertPixel.username, now.toISOString());
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
...insertPixel,
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCanvasConfig(): Promise<CanvasConfig> {
|
||||||
|
const row = this.db.prepare('SELECT * FROM canvas_config ORDER BY updatedAt DESC LIMIT 1').get() as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
enableAutomaticEvents: Boolean(row.enableAutomaticEvents),
|
||||||
|
updatedAt: new Date(row.updatedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCanvasConfig(configUpdate: InsertCanvasConfig): Promise<CanvasConfig> {
|
||||||
|
const currentConfig = await this.getCanvasConfig();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE canvas_config
|
||||||
|
SET canvasWidth = ?, canvasHeight = ?, defaultCooldown = ?,
|
||||||
|
enableAutomaticEvents = ?, eventDuration = ?, eventInterval = ?,
|
||||||
|
updatedAt = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
configUpdate.canvasWidth ?? currentConfig.canvasWidth,
|
||||||
|
configUpdate.canvasHeight ?? currentConfig.canvasHeight,
|
||||||
|
configUpdate.defaultCooldown ?? currentConfig.defaultCooldown,
|
||||||
|
configUpdate.enableAutomaticEvents ? 1 : 0,
|
||||||
|
configUpdate.eventDuration ?? currentConfig.eventDuration,
|
||||||
|
configUpdate.eventInterval ?? currentConfig.eventInterval,
|
||||||
|
now.toISOString(),
|
||||||
|
currentConfig.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getCanvasConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserCooldown(userId: string): Promise<UserCooldown | undefined> {
|
||||||
|
const row = this.db.prepare('SELECT * FROM user_cooldowns WHERE userId = ?').get(userId) as any;
|
||||||
|
if (!row) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
lastPlacement: new Date(row.lastPlacement),
|
||||||
|
cooldownEnds: new Date(row.cooldownEnds)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setUserCooldown(insertCooldown: InsertUserCooldown): Promise<UserCooldown> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO user_cooldowns (id, userId, lastPlacement, cooldownEnds)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(id, insertCooldown.userId, now.toISOString(), insertCooldown.cooldownEnds.toISOString());
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: insertCooldown.userId,
|
||||||
|
lastPlacement: now,
|
||||||
|
cooldownEnds: insertCooldown.cooldownEnds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentPlacements(limit: number = 10): Promise<Pixel[]> {
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
SELECT * FROM pixels
|
||||||
|
ORDER BY createdAt DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(limit) as any[];
|
||||||
|
|
||||||
|
return rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
createdAt: new Date(row.createdAt)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas-Erweiterungsmethode
|
||||||
|
async expandCanvas(newWidth: number, newHeight: number): Promise<void> {
|
||||||
|
const currentConfig = await this.getCanvasConfig();
|
||||||
|
|
||||||
|
if (newWidth < currentConfig.canvasWidth || newHeight < currentConfig.canvasHeight) {
|
||||||
|
throw new Error("Canvas kann nur erweitert werden, nicht verkleinert");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateCanvasConfig({
|
||||||
|
canvasWidth: newWidth,
|
||||||
|
canvasHeight: newHeight,
|
||||||
|
defaultCooldown: currentConfig.defaultCooldown,
|
||||||
|
enableAutomaticEvents: currentConfig.enableAutomaticEvents,
|
||||||
|
eventDuration: currentConfig.eventDuration,
|
||||||
|
eventInterval: currentConfig.eventInterval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePixel(pixelId: string): Promise<void> {
|
||||||
|
this.db.prepare("DELETE FROM pixels WHERE id = ?").run(pixelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCanvas(): Promise<void> {
|
||||||
|
this.db.prepare("DELETE FROM pixels").run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,21 @@ export interface IStorage {
|
|||||||
getPixel(x: number, y: number): Promise<Pixel | undefined>;
|
getPixel(x: number, y: number): Promise<Pixel | undefined>;
|
||||||
getAllPixels(): Promise<Pixel[]>;
|
getAllPixels(): Promise<Pixel[]>;
|
||||||
placePixel(pixel: InsertPixel): Promise<Pixel>;
|
placePixel(pixel: InsertPixel): Promise<Pixel>;
|
||||||
|
|
||||||
// Config operations
|
// Config operations
|
||||||
getCanvasConfig(): Promise<CanvasConfig>;
|
getCanvasConfig(): Promise<CanvasConfig>;
|
||||||
updateCanvasConfig(config: InsertCanvasConfig): Promise<CanvasConfig>;
|
updateCanvasConfig(config: InsertCanvasConfig): Promise<CanvasConfig>;
|
||||||
|
|
||||||
// User cooldown operations
|
// User cooldown operations
|
||||||
getUserCooldown(userId: string): Promise<UserCooldown | undefined>;
|
getUserCooldown(userId: string): Promise<UserCooldown | undefined>;
|
||||||
setUserCooldown(cooldown: InsertUserCooldown): Promise<UserCooldown>;
|
setUserCooldown(cooldown: InsertUserCooldown): Promise<UserCooldown>;
|
||||||
|
|
||||||
// Recent activity
|
// Recent activity
|
||||||
getRecentPlacements(limit?: number): Promise<Pixel[]>;
|
getRecentPlacements(limit?: number): Promise<Pixel[]>;
|
||||||
|
|
||||||
|
// Admin operations
|
||||||
|
deletePixel(pixelId: string): Promise<void>;
|
||||||
|
clearCanvas(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemStorage implements IStorage {
|
export class MemStorage implements IStorage {
|
||||||
@@ -60,7 +64,7 @@ export class MemStorage implements IStorage {
|
|||||||
id,
|
id,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.pixels.set(this.getPixelKey(pixel.x, pixel.y), pixel);
|
this.pixels.set(this.getPixelKey(pixel.x, pixel.y), pixel);
|
||||||
return pixel;
|
return pixel;
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,7 @@ export class MemStorage implements IStorage {
|
|||||||
id,
|
id,
|
||||||
lastPlacement: new Date(),
|
lastPlacement: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.userCooldowns.set(cooldown.userId, cooldown);
|
this.userCooldowns.set(cooldown.userId, cooldown);
|
||||||
return cooldown;
|
return cooldown;
|
||||||
}
|
}
|
||||||
@@ -100,6 +104,21 @@ export class MemStorage implements IStorage {
|
|||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deletePixel(pixelId: string): Promise<void> {
|
||||||
|
this.pixels.delete(pixelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCanvas(): Promise<void> {
|
||||||
|
this.pixels.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new MemStorage();
|
// The SQLiteStorage import and usage below is not part of the changes,
|
||||||
|
// but is included to ensure the file is complete as per instructions.
|
||||||
|
import { SQLiteStorage } from "./sqlite-storage";
|
||||||
|
|
||||||
|
// Verwende SQLite im Development-Modus, Memory-Storage in Production
|
||||||
|
export const storage = process.env.NODE_ENV === 'development'
|
||||||
|
? new SQLiteStorage('./dev-database.sqlite')
|
||||||
|
: new MemStorage();
|
||||||
21
server/types/keycloak.d.ts
vendored
Normal file
21
server/types/keycloak.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
import 'express';
|
||||||
|
|
||||||
|
declare module 'express' {
|
||||||
|
interface Request {
|
||||||
|
kauth?: {
|
||||||
|
grant?: {
|
||||||
|
access_token?: {
|
||||||
|
content?: {
|
||||||
|
sub?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
login?: (req: Request, res: Response) => void;
|
||||||
|
logout?: (req: Request, res: Response) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,4 +83,9 @@ export const wsMessageSchema = z.union([
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type WSMessage = z.infer<typeof wsMessageSchema>;
|
export type WSMessage =
|
||||||
|
| { type: "pixel_placed"; data: { x: number; y: number; color: string; userId: string; username: string; timestamp: string } }
|
||||||
|
| { type: "user_count"; data: { count: number } }
|
||||||
|
| { type: "cooldown_update"; data: { userId: string; remainingSeconds: number } }
|
||||||
|
| { type: "pixel_deleted"; data: { pixelId: string } }
|
||||||
|
| { type: "canvas_cleared"; data: {} };
|
||||||
Reference in New Issue
Block a user