feat: add LogoManagementModal component for logo upload and management

This commit is contained in:
Lars Behrends
2025-12-28 21:19:10 +01:00
parent 2481187fe7
commit 81f1e90b85
13 changed files with 2963 additions and 52 deletions

View File

@@ -13,6 +13,9 @@ RUN npm install
# Copy source files
COPY backend/ /app/
# Create uploads directory
RUN mkdir -p /app/uploads
# Expose port
EXPOSE 3000

View File

@@ -81,7 +81,7 @@ const SEED_PROJECTS = [
hiring: 0,
foundedDate: 'Zyklus 12',
associatedOrgId: 'org-4',
bannerUrl: 'images/screenshots/2025-12-28_01.11.49.png',
bannerImageId: null,
shopCatalog: JSON.stringify([])
}
];
@@ -130,10 +130,23 @@ function setupTables() {
status VARCHAR(50),
mayor VARCHAR(255),
establishedYear VARCHAR(100),
bannerUrl VARCHAR(255),
bannerImageId VARCHAR(50),
logoImageId VARCHAR(50),
gallery JSON,
cityStats JSON
)`,
`CREATE TABLE IF NOT EXISTS images (
id VARCHAR(50) PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
originalName VARCHAR(255),
mimeType VARCHAR(100),
size INTEGER,
uploadDate DATETIME DEFAULT CURRENT_TIMESTAMP,
altText VARCHAR(255),
entityType VARCHAR(50), -- 'project', 'org', 'player'
entityId VARCHAR(50), -- ID of the owning entity
imageType VARCHAR(50) -- 'banner', 'gallery'
)`,
`CREATE TABLE IF NOT EXISTS projects (
id VARCHAR(50) PRIMARY KEY,
title VARCHAR(255),
@@ -146,7 +159,8 @@ function setupTables() {
hiring TINYINT,
foundedDate VARCHAR(100),
associatedOrgId VARCHAR(50),
bannerUrl VARCHAR(255),
bannerImageId VARCHAR(50),
logoImageId VARCHAR(50),
shopCatalog JSON,
gallery JSON
)`
@@ -177,8 +191,8 @@ function seedData() {
if (!err && rows[0].count === 0) {
console.log("Seeding Orgs...");
SEED_ORGS.forEach(o => {
pool.query("INSERT INTO orgs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[o.id, o.name, o.type, o.description, o.memberCount, o.status, o.mayor, o.establishedYear, o.bannerUrl, o.gallery, o.cityStats]);
pool.query("INSERT INTO orgs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[o.id, o.name, o.type, o.description, o.memberCount, o.status, o.mayor, o.establishedYear, null, null, o.gallery, o.cityStats]);
});
}
});
@@ -187,8 +201,8 @@ function seedData() {
if (!err && rows[0].count === 0) {
console.log("Seeding Projects...");
SEED_PROJECTS.forEach(p => {
pool.query("INSERT INTO projects VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[p.id, p.title, p.description, p.category, p.status, p.progress, p.owner, p.employees, p.hiring, p.foundedDate, p.associatedOrgId, p.bannerUrl, p.shopCatalog, '[]']);
pool.query("INSERT INTO projects VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[p.id, p.title, p.description, p.category, p.status, p.progress, p.owner, p.employees, p.hiring, p.foundedDate, p.associatedOrgId, p.bannerImageId, null, p.shopCatalog, '[]']);
});
}
});

1231
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,12 @@
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"express-session": "^1.17.3",
"multer": "^2.0.2",
"mysql2": "^3.6.0",
"passport": "^0.6.0",
"passport-discord": "^0.1.4",
"cors": "^2.8.5",
"mysql2": "^3.6.0"
"passport-discord": "^0.1.4"
}
}

View File

@@ -3,6 +3,9 @@ const session = require('express-session');
const passport = require('passport');
const DiscordStrategy = require('passport-discord').Strategy;
const cors = require('cors');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { db, init } = require('./database');
const app = express();
@@ -13,6 +16,40 @@ const CLIENT_ID = process.env.DISCORD_CLIENT_ID || 'mock_id';
const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET || 'mock_secret';
const CALLBACK_URL = process.env.CALLBACK_URL || 'http://localhost:3000/auth/discord/callback';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8000';
const BACKEND_URL = process.env.BACKEND_URL || 'https://vollidioten.ceraticsoft.de';
// File upload configuration
const UPLOAD_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
// Generate unique filename with timestamp
const uniqueName = Date.now() + '-' + Math.round(Math.random() * 1E9) + path.extname(file.originalname);
cb(null, uniqueName);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (req, file, cb) => {
// Allow only image files
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Nur Bilddateien sind erlaubt'), false);
}
}
});
init(); // Initialize DB
@@ -239,13 +276,32 @@ app.get('/api/orgs', (req, res) => {
if (err) return res.status(500).json({error: err.message});
const parsed = rows.map(r => ({
...r,
gallery: JSON.parse(r.gallery),
gallery: JSON.parse(r.gallery || '[]'),
bannerUrl: getImageUrl(r.bannerImageId),
logoUrl: getImageUrl(r.logoImageId),
cityStats: r.cityStats ? JSON.parse(r.cityStats) : undefined
}));
res.json(parsed);
});
});
// Helper function to get full image URL
function getImageUrl(imageId) {
if (!imageId) return null;
return `${BACKEND_URL}/api/images/${imageId}`;
}
// Helper function to resolve gallery image IDs to URLs
function resolveGalleryImages(gallery) {
if (!gallery) return [];
try {
const imageIds = JSON.parse(gallery);
return imageIds.map(id => ({ id, url: getImageUrl(id) }));
} catch (e) {
return [];
}
}
// API: Projects
app.get('/api/projects', (req, res) => {
db.all("SELECT * FROM projects", (err, rows) => {
@@ -254,7 +310,9 @@ app.get('/api/projects', (req, res) => {
...r,
employees: JSON.parse(r.employees),
shopCatalog: JSON.parse(r.shopCatalog),
gallery: JSON.parse(r.gallery),
gallery: resolveGalleryImages(r.gallery),
bannerUrl: getImageUrl(r.bannerImageId),
logoUrl: getImageUrl(r.logoImageId),
hiring: !!r.hiring
}));
res.json(parsed);
@@ -283,8 +341,8 @@ app.post('/api/projects', (req, res) => {
const projectId = 'proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
db.run(`INSERT INTO projects
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerUrl, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', 0, ?, '[]', 0, ?, ?, '', '[]', '[]')`,
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerImageId, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', 0, ?, '[]', 0, ?, ?, null, '[]', '[]')`,
[projectId, title, description, category || 'Enterprise', projectOwner,
new Date().toISOString().split('T')[0], associatedOrgId || null],
function(err) {
@@ -458,8 +516,8 @@ app.post('/api/admin/npc-company', (req, res) => {
const projectId = 'npc_proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
db.run(`INSERT INTO projects
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerUrl, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', ?, ?, '[]', 0, ?, ?, '', ?, '[]')`,
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerImageId, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', ?, ?, '[]', 0, ?, ?, null, ?, '[]')`,
[projectId, title, description || `${title} - Eine NPC-Firma im Obsidian-Tal.`, category || 'Enterprise',
Math.floor(Math.random() * 100), owner, new Date().toISOString().split('T')[0],
associatedOrgId || null, JSON.stringify(shopCatalog || [])],
@@ -882,11 +940,10 @@ app.post('/api/projects/:projectId/gallery', (req, res) => {
});
// Delete gallery image
app.delete('/api/projects/:projectId/gallery/:imageIndex', (req, res) => {
app.delete('/api/projects/:projectId/gallery/:imageId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
const { projectId, imageIndex } = req.params;
const index = parseInt(imageIndex);
const { projectId, imageId } = req.params;
// Check ownership
db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
@@ -898,25 +955,638 @@ app.delete('/api/projects/:projectId/gallery/:imageIndex', (req, res) => {
try {
const gallery = JSON.parse(row.gallery || '[]');
const imageIndex = gallery.indexOf(imageId);
if (index < 0 || index >= gallery.length) {
return res.status(404).json({error: 'Bild nicht gefunden'});
if (imageIndex === -1) {
return res.status(404).json({error: 'Bild nicht in Galerie gefunden'});
}
gallery.splice(index, 1);
// Remove from gallery array
gallery.splice(imageIndex, 1);
// Update project gallery
db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) {
if (err) {
console.error('Error updating gallery:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren der Galerie'});
}
// Delete image record and file
db.get("SELECT filename FROM images WHERE id = ?", [imageId], (err, imageRow) => {
if (err) {
console.error('Error finding image record:', err);
return res.status(500).json({error: 'Fehler beim Löschen des Bildes'});
}
if (imageRow) {
// Delete file from filesystem
const filePath = path.join(UPLOAD_DIR, imageRow.filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (fileErr) {
console.error('Error deleting file:', fileErr);
}
// Delete image record from database
db.run("DELETE FROM images WHERE id = ?", [imageId], function(err) {
if (err) {
console.error('Error deleting image record:', err);
return res.status(500).json({error: 'Fehler beim Löschen des Bildes'});
}
res.json({
success: true,
gallery: gallery,
message: 'Bild erfolgreich gelöscht'
});
});
} else {
// Image record not found, but gallery was updated
res.json({
success: true,
gallery: gallery,
message: 'Bild aus Galerie entfernt'
});
}
});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Galerie-Daten'});
}
});
});
// === IMAGE MANAGEMENT API ===
// Get image by ID
app.get('/api/images/:imageId', (req, res) => {
const { imageId } = req.params;
db.get("SELECT * FROM images WHERE id = ?", [imageId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Bild nicht gefunden'});
// Serve the actual image file
const filePath = path.join(UPLOAD_DIR, row.filename);
if (fs.existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.status(404).json({error: 'Bild-Datei nicht gefunden'});
}
});
});
// Get image metadata by ID
app.get('/api/images/:imageId/meta', (req, res) => {
const { imageId } = req.params;
db.get("SELECT * FROM images WHERE id = ?", [imageId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Bild nicht gefunden'});
res.json({
id: row.id,
filename: row.filename,
originalName: row.originalName,
mimeType: row.mimeType,
size: row.size,
uploadDate: row.uploadDate,
altText: row.altText,
url: `${BACKEND_URL}/api/images/${row.id}`
});
});
});
// Upload banner image
app.post('/api/projects/:projectId/banner/upload', upload.single('banner'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
const { projectId } = req.params;
// Check ownership
db.get("SELECT owner FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || null,
entityType: 'project',
entityId: projectId,
imageType: 'banner'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Bildes'});
}
// Update project banner
db.run("UPDATE projects SET bannerImageId = ? WHERE id = ?", [imageId, projectId], function(err) {
if (err) {
console.error('Error updating banner:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Banners'});
}
res.json({
success: true,
imageId: imageId,
imageUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Banner erfolgreich hochgeladen'
});
});
});
});
});
// Upload gallery image
app.post('/api/projects/:projectId/gallery/upload', upload.single('gallery'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
const { projectId } = req.params;
// Check ownership
db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || null,
entityType: 'project',
entityId: projectId,
imageType: 'gallery'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Bildes'});
}
try {
// Add image ID to gallery array
const gallery = JSON.parse(row.gallery || '[]');
gallery.push(imageId);
db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) {
if (err) {
console.error('Error deleting gallery image:', err);
return res.status(500).json({error: 'Fehler beim Löschen'});
console.error('Error updating gallery:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren der Galerie'});
}
res.json({success: true, gallery});
res.json({
success: true,
imageId: imageId,
imageUrl: `${BACKEND_URL}/api/images/${imageId}`,
gallery: gallery,
message: 'Bild erfolgreich zur Galerie hinzugefügt'
});
});
} catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'});
console.error('Error parsing gallery:', e);
res.status(500).json({error: 'Fehler beim Verarbeiten der Galerie-Daten'});
}
});
});
});
// Upload logo image
app.post('/api/projects/:projectId/logo/upload', upload.single('logo'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
const { projectId } = req.params;
// Check ownership
db.get("SELECT owner FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.title} Logo`,
entityType: 'project',
entityId: projectId,
imageType: 'logo'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating logo image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Logos'});
}
// Update project logo
db.run("UPDATE projects SET logoImageId = ? WHERE id = ?", [imageId, projectId], function(err) {
if (err) {
console.error('Error updating logo:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Logos'});
}
res.json({
success: true,
imageId: imageId,
logoUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Logo erfolgreich hochgeladen'
});
});
});
});
});
// === CITY MANAGEMENT API (Admin Only) ===
// Create new city
app.post('/api/admin/cities', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { name, description, mayor, establishedYear, cityStats } = req.body;
if (!name) {
return res.status(400).json({error: 'Stadt-Name erforderlich'});
}
// Generate unique ID
const cityId = 'city_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const cityData = {
id: cityId,
name: name.trim(),
type: 'City',
description: description || `${name} - Eine Stadt im Obsidian-Tal.`,
memberCount: 0,
status: 'active',
mayor: mayor || '',
establishedYear: establishedYear || new Date().getFullYear().toString(),
bannerImageId: null,
logoImageId: null,
gallery: '[]',
cityStats: cityStats ? JSON.stringify(cityStats) : JSON.stringify({
taxRate: 5.0,
biome: 'Ebene',
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
})
};
db.run(`INSERT INTO orgs (id, name, type, description, memberCount, status, mayor, establishedYear, bannerImageId, logoImageId, gallery, cityStats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[cityData.id, cityData.name, cityData.type, cityData.description, cityData.memberCount, cityData.status,
cityData.mayor, cityData.establishedYear, cityData.bannerImageId, cityData.logoImageId, cityData.gallery, cityData.cityStats],
function(err) {
if (err) {
console.error('Error creating city:', err);
return res.status(500).json({error: 'Fehler beim Erstellen der Stadt'});
}
res.json({
success: true,
cityId: cityId,
message: 'Stadt erfolgreich erstellt'
});
}
);
});
// Update city
app.put('/api/admin/cities/:cityId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
const updates = req.body;
// Build dynamic update query
const allowedFields = ['name', 'description', 'mayor', 'establishedYear', 'status', 'cityStats'];
const updateFields = [];
const values = [];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
if (field === 'cityStats') {
updateFields.push(`${field} = ?`);
values.push(JSON.stringify(updates[field]));
} else {
updateFields.push(`${field} = ?`);
values.push(updates[field]);
}
}
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE orgs SET ${updateFields.join(', ')} WHERE id = ? AND type = 'City'`;
values.push(cityId);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating city:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Stadt nicht gefunden'});
}
res.json({success: true, message: 'Stadt erfolgreich aktualisiert'});
});
});
// Delete city
app.delete('/api/admin/cities/:cityId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
db.run("DELETE FROM orgs WHERE id = ? AND type = 'City'", [cityId], function(err) {
if (err) {
console.error('Error deleting city:', err);
return res.status(500).json({error: 'Fehler beim Löschen'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Stadt nicht gefunden'});
}
res.json({success: true, message: 'Stadt erfolgreich gelöscht'});
});
});
// Upload city banner
app.post('/api/admin/cities/:cityId/banner/upload', upload.single('banner'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
// Check if city exists
db.get("SELECT name FROM orgs WHERE id = ? AND type = 'City'", [cityId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Stadt nicht gefunden'});
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.name} Banner`,
entityType: 'org',
entityId: cityId,
imageType: 'banner'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating city banner image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Banners'});
}
// Update city banner
db.run("UPDATE orgs SET bannerImageId = ? WHERE id = ? AND type = 'City'", [imageId, cityId], function(err) {
if (err) {
console.error('Error updating city banner:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Banners'});
}
res.json({
success: true,
imageId: imageId,
bannerUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Banner erfolgreich hochgeladen'
});
});
});
});
});
// Upload city logo
app.post('/api/admin/cities/:cityId/logo/upload', upload.single('logo'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
// Check if city exists
db.get("SELECT name FROM orgs WHERE id = ? AND type = 'City'", [cityId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Stadt nicht gefunden'});
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.name} Logo`,
entityType: 'org',
entityId: cityId,
imageType: 'logo'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating city logo image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Logos'});
}
// Update city logo
db.run("UPDATE orgs SET logoImageId = ? WHERE id = ? AND type = 'City'", [imageId, cityId], function(err) {
if (err) {
console.error('Error updating city logo:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Logos'});
}
res.json({
success: true,
imageId: imageId,
logoUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Logo erfolgreich hochgeladen'
});
});
});
});
});
// Upload city gallery image
app.post('/api/admin/cities/:cityId/gallery/upload', upload.single('gallery'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
// Check if city exists
db.get("SELECT name, gallery FROM orgs WHERE id = ? AND type = 'City'", [cityId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Stadt nicht gefunden'});
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.name} Galerie`,
entityType: 'org',
entityId: cityId,
imageType: 'gallery'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating city gallery image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Bildes'});
}
try {
// Add image ID to gallery array
const gallery = JSON.parse(row.gallery || '[]');
gallery.push(imageId);
db.run("UPDATE orgs SET gallery = ? WHERE id = ? AND type = 'City'", [JSON.stringify(gallery), cityId], function(err) {
if (err) {
console.error('Error updating city gallery:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren der Galerie'});
}
res.json({
success: true,
imageId: imageId,
imageUrl: `${BACKEND_URL}/api/images/${imageId}`,
gallery: gallery,
message: 'Bild erfolgreich zur Galerie hinzugefügt'
});
});
} catch (e) {
console.error('Error parsing city gallery:', e);
res.status(500).json({error: 'Fehler beim Verarbeiten der Galerie-Daten'});
}
});
});
});
// Serve uploaded files statically
app.use('/uploads', express.static(UPLOAD_DIR));
app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);

View File

@@ -131,6 +131,61 @@ const BannerManagementModal: React.FC<BannerManagementModalProps> = ({
</p>
</div>
{/* File Upload */}
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-3">Oder Bild hochladen</h4>
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('banner', file);
try {
setLoading(true);
setError(null);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/banner/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
const data = await response.json();
setBannerUrl(data.bannerUrl);
onUpdate();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Hochladen');
}
} catch (err) {
console.error('Error uploading banner:', err);
setError('Netzwerkfehler beim Hochladen');
} finally {
setLoading(false);
}
}}
className="hidden"
id="banner-upload"
/>
<label htmlFor="banner-upload" className="cursor-pointer">
<div className="w-12 h-12 mx-auto mb-4 bg-surfaceHighlight rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-accentInfo" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p className="text-sm text-textMain font-medium">Bild auswählen</p>
<p className="text-xs text-textMuted mt-1">Max. 10MB, nur Bilddateien</p>
</label>
</div>
</div>
{/* Preview */}
{bannerUrl && bannerUrl !== currentBannerUrl && (
<div className="mb-6">

View File

@@ -1,6 +1,11 @@
import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet';
interface GalleryImage {
id: string;
url: string;
}
interface GalleryManagementModalProps {
isOpen: boolean;
onClose: () => void;
@@ -14,7 +19,7 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
projectId,
onUpdate
}) => {
const [images, setImages] = useState<string[]>([]);
const [images, setImages] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -73,10 +78,10 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
}
};
const removeImage = async (index: number) => {
const removeImage = async (imageId: string) => {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/${index}`, {
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/${imageId}`, {
method: 'DELETE',
credentials: 'include'
});
@@ -118,7 +123,9 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
{/* Add Image */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 mb-6">
<h4 className="font-semibold text-textMain mb-4">Bild hinzufügen</h4>
<div className="flex gap-2">
{/* URL Input */}
<div className="flex gap-2 mb-4">
<input
type="url"
value={imageUrl}
@@ -134,9 +141,68 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
Hinzufügen
</button>
</div>
<p className="text-xs text-textMuted mt-2">
<p className="text-xs text-textMuted mb-4">
Geben Sie eine direkte URL zu einem Bild ein (z.B. von Imgur, Discord, etc.)
</p>
{/* File Upload */}
<div className="border-t border-border pt-4">
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
multiple
onChange={async (e) => {
const files = e.target.files as FileList;
if (!files || files.length === 0) return;
try {
setLoading(true);
setError(null);
// Upload each file
const fileArray = Array.from(files) as File[];
for (const file of fileArray) {
const formData = new FormData();
formData.append('gallery', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
setError(errorData.error || `Fehler beim Hochladen von ${file.name}`);
break;
}
}
// Reload gallery after all uploads
await loadGallery();
} catch (err) {
console.error('Error uploading images:', err);
setError('Netzwerkfehler beim Hochladen');
} finally {
setLoading(false);
}
}}
className="hidden"
id="gallery-upload"
/>
<label htmlFor="gallery-upload" className="cursor-pointer">
<div className="w-10 h-10 mx-auto mb-3 bg-surfaceHighlight rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-accentInfo" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p className="text-sm text-textMain font-medium">Bilder auswählen</p>
<p className="text-xs text-textMuted mt-1">Max. 10MB pro Bild, Mehrfachauswahl möglich</p>
</label>
</div>
</div>
</div>
{/* Gallery Grid */}
@@ -156,11 +222,11 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((imageUrl, index) => (
<div key={index} className="relative group">
{images.map((image, index) => (
<div key={image.id} className="relative group">
<div className="aspect-square rounded-lg overflow-hidden border border-border bg-surfaceHighlight/30">
<img
src={imageUrl}
src={image.url}
alt={`Galerie ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
onError={(e) => {
@@ -172,7 +238,7 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
{/* Delete Button */}
<button
onClick={() => removeImage(index)}
onClick={() => removeImage(image.id)}
disabled={loading}
className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
title="Bild entfernen"

View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { Icons } from './IconSet';
interface LogoManagementModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
currentLogoUrl?: string;
onUpdate: () => void;
}
const LogoManagementModal: React.FC<LogoManagementModalProps> = ({
isOpen,
onClose,
projectId,
currentLogoUrl,
onUpdate
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileUpload = async (file: File) => {
try {
setLoading(true);
setError(null);
const formData = new FormData();
formData.append('logo', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/logo/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
const data = await response.json();
console.log('Logo uploaded successfully:', data);
onUpdate();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Hochladen des Logos');
}
} catch (err) {
console.error('Error uploading logo:', err);
setError('Netzwerkfehler beim Hochladen');
} finally {
setLoading(false);
}
};
const removeLogo = async () => {
// Note: We'll need to implement logo deletion in the backend
// For now, this is a placeholder
alert('Logo-Löschen ist noch nicht implementiert. Kontaktieren Sie einen Administrator.');
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-md shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Layers className="w-5 h-5" />
Logo verwalten
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Current Logo Preview */}
{currentLogoUrl && (
<div className="mb-6">
<h4 className="text-sm font-semibold text-textMain mb-3">Aktuelles Logo</h4>
<div className="flex items-center gap-4 p-4 bg-surfaceHighlight/30 border border-border rounded-lg">
<img
src={currentLogoUrl}
alt="Aktuelles Logo"
className="w-16 h-16 rounded-lg object-cover border border-white/10"
/>
<div className="flex-1">
<p className="text-sm text-textMuted">Logo ist gesetzt</p>
<button
onClick={removeLogo}
className="text-xs text-red-400 hover:text-red-300 mt-1"
disabled={loading}
>
Logo entfernen
</button>
</div>
</div>
</div>
)}
{/* File Upload */}
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}}
className="hidden"
id="logo-upload"
disabled={loading}
/>
<label htmlFor="logo-upload" className="cursor-pointer">
<div className="w-12 h-12 mx-auto mb-4 bg-surfaceHighlight rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-accentInfo" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p className="text-sm text-textMain font-medium">
{loading ? 'Lädt hoch...' : 'Logo auswählen'}
</p>
<p className="text-xs text-textMuted mt-2">
PNG, JPG oder GIF Max. 2MB Quadratische Formate bevorzugt
</p>
</label>
</div>
<div className="mt-4 text-xs text-textMuted">
<p> Das Logo wird in der Projektübersicht angezeigt</p>
<p> Verwenden Sie transparente Hintergründe für beste Ergebnisse</p>
<p> Das Logo wird automatisch auf 64x64px skaliert</p>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
};
export default LogoManagementModal;

View File

@@ -25,10 +25,13 @@ services:
- SESSION_SECRET=${SESSION_SECRET}
- CALLBACK_URL=https://vollidioten.ceraticsoft.de/auth/discord/callback
- FRONTEND_URL=https://vollidioten.ceraticsoft.de
- BACKEND_URL=https://vollidioten.ceraticsoft.de
- DB_HOST=${DB_HOST:-db}
- DB_USER=${DB_USER:-obsidian_user}
- DB_PASS=${DB_PASS:-obsidian_pass}
- DB_NAME=${DB_NAME:-obsidian_db}
volumes:
- ./backend/uploads:/app/uploads
restart: always
networks:
- external_web

View File

@@ -647,8 +647,9 @@ const EditNpcCompanyCard: React.FC<{ company: any; npcCitizens: any[]; onUpdate:
const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
const [user, setUser] = useState(authService.getUser());
const [activeTab, setActiveTab] = useState<'overview' | 'npcs' | 'create-npc' | 'edit-npcs' | 'manage-admins'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'npcs' | 'create-npc' | 'edit-npcs' | 'cities' | 'create-city' | 'manage-admins'>('overview');
const [npcs, setNpcs] = useState<any>({ citizens: [], companies: [] });
const [cities, setCities] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [shopModalState, setShopModalState] = useState<{
@@ -669,6 +670,9 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
if (activeTab === 'npcs' && isAdmin) {
loadNpcs();
}
if (activeTab === 'cities' && isAdmin) {
loadCities();
}
}, [activeTab, isAdmin]);
const loadNpcs = async () => {
@@ -692,6 +696,29 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
}
};
const loadCities = async () => {
try {
setLoading(true);
const response = await fetch('https://vollidioten.ceraticsoft.de/api/orgs', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
// Filter only cities (type === 'City')
const cityData = data.filter((org: any) => org.type === 'City');
setCities(cityData);
} else {
setError('Fehler beim Laden der Städte');
}
} catch (err) {
console.error('Error loading cities:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
// NPC Creation Forms
const [citizenForm, setCitizenForm] = useState({
username: '',
@@ -710,6 +737,30 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
shopItems: ''
});
// City management
const [cityForm, setCityForm] = useState({
name: '',
description: '',
mayor: '',
establishedYear: '',
cityStats: JSON.stringify({
taxRate: 5.0,
biome: 'Ebene',
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
}, null, 2)
});
const [editingCity, setEditingCity] = useState<any>(null);
const [editCityForm, setEditCityForm] = useState({
name: '',
description: '',
mayor: '',
establishedYear: '',
cityStats: ''
});
const createNpcCitizen = async () => {
if (!citizenForm.username.trim()) {
setError('NPC-Name ist erforderlich');
@@ -870,6 +921,18 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
>
NPCs erstellen
</button>
<button
onClick={() => setActiveTab('cities')}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'cities' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
>
Städte verwalten
</button>
<button
onClick={() => setActiveTab('create-city')}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'create-city' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
>
Stadt erstellen
</button>
</div>
{/* Error Display */}
@@ -1230,6 +1293,604 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
</div>
)}
{activeTab === 'cities' && (
<div className="space-y-8">
{/* Cities Management */}
<div className="bg-surface border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold flex items-center gap-2">
<Icons.Map className="w-5 h-5 text-green-400" />
Städte verwalten ({cities.length})
</h3>
<button
onClick={() => loadCities()}
disabled={loading}
className="text-accentInfo hover:text-accentInfo/80 text-sm disabled:opacity-50"
title="Aktualisieren"
>
{loading ? 'Lade...' : 'Aktualisieren'}
</button>
</div>
{cities.length === 0 ? (
<p className="text-textMuted">Keine Städte vorhanden.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cities.map((city: any) => (
<div key={city.id} className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
{city.logoUrl ? (
<img
src={city.logoUrl}
alt={`${city.name} Logo`}
className="w-8 h-8 rounded border border-white/10 object-cover"
/>
) : (
<div className="w-8 h-8 bg-green-500/20 rounded flex items-center justify-center text-xs font-bold text-green-400">
{city.name.charAt(0)}
</div>
)}
<div>
<h4 className="font-medium text-white">{city.name}</h4>
<p className="text-xs text-textMuted">Gegr. {city.establishedYear}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditingCity(city);
setEditCityForm({
name: city.name,
description: city.description || '',
mayor: city.mayor || '',
establishedYear: city.establishedYear || '',
cityStats: JSON.stringify(city.cityStats || {}, null, 2)
});
}}
className="text-blue-400 hover:text-blue-300 text-sm"
title="Stadt bearbeiten"
>
<Icons.Edit className="w-4 h-4" />
</button>
<button
onClick={async () => {
if (confirm(`Stadt "${city.name}" wirklich löschen?`)) {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${city.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
alert('Stadt erfolgreich gelöscht!');
loadCities(); // Reload cities
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Löschen');
}
} catch (err) {
console.error('Error deleting city:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
}
}}
disabled={loading}
className="text-red-400 hover:text-red-300 text-sm disabled:opacity-50"
title="Stadt löschen"
>
🗑
</button>
</div>
</div>
<p className="text-sm text-textMuted mb-2">{city.description}</p>
<div className="text-xs text-textMuted">
Bürgermeister: {city.mayor || 'Unbekannt'} {city.memberCount} Bürger
</div>
{city.cityStats && (
<div className="mt-2 text-xs text-accentInfo">
Spezialität: {city.cityStats.specialty}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'create-city' && (
<div className="space-y-8">
{/* Create City */}
<div className="bg-surface border border-border rounded-xl p-6">
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
<Icons.Map className="w-5 h-5 text-green-400" />
Stadt erstellen
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-textMain mb-1">Stadtname *</label>
<input
type="text"
value={cityForm.name}
onChange={(e) => setCityForm({...cityForm, name: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Eldoria"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Bürgermeister</label>
<input
type="text"
value={cityForm.mayor}
onChange={(e) => setCityForm({...cityForm, mayor: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Lord Eldric"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Gründungsjahr</label>
<input
type="text"
value={cityForm.establishedYear}
onChange={(e) => setCityForm({...cityForm, establishedYear: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Ära 5"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Beschreibung</label>
<textarea
value={cityForm.description}
onChange={(e) => setCityForm({...cityForm, description: e.target.value})}
className="w-full h-20 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="Beschreibung der Stadt..."
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Stadt-Statistiken (JSON-Format)</label>
<textarea
value={cityForm.cityStats}
onChange={(e) => setCityForm({...cityForm, cityStats: e.target.value})}
className="w-full h-24 bg-[#0b0b0d] border border-border rounded p-2 text-sm font-mono text-xs"
placeholder='{ "taxRate": 5.0, "biome": "Ebene", "defenseRating": 5, "government": "Demokratie", "specialty": "Handel" }'
/>
<p className="text-xs text-textMuted mt-1">JSON-Objekt mit Stadt-Statistiken</p>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-2">Banner-Bild (optional)</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCityForm({...cityForm, bannerFile: file});
}
}}
className="hidden"
id="city-banner-upload"
disabled={loading}
/>
<label htmlFor="city-banner-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-accentInfo/20 rounded flex items-center justify-center">
<Icons.Map className="w-4 h-4 text-accentInfo" />
</div>
<p className="text-xs text-textMain font-medium">
Banner auswählen
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 5MB</p>
</label>
</div>
{cityForm.bannerFile && (
<div className="mt-2 text-xs text-green-400">
Banner ausgewählt: {cityForm.bannerFile.name}
</div>
)}
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-2">Logo-Bild (optional)</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCityForm({...cityForm, logoFile: file});
}
}}
className="hidden"
id="city-logo-upload"
disabled={loading}
/>
<label htmlFor="city-logo-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-green-500/20 rounded flex items-center justify-center">
<Icons.Layers className="w-4 h-4 text-green-400" />
</div>
<p className="text-xs text-textMain font-medium">
Logo auswählen
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 2MB Quadratisch bevorzugt</p>
</label>
</div>
{cityForm.logoFile && (
<div className="mt-2 text-xs text-green-400">
Logo ausgewählt: {cityForm.logoFile.name}
</div>
)}
</div>
<div className="md:col-span-2 pt-2">
<button
onClick={async () => {
if (!cityForm.name.trim()) {
setError('Stadt-Name ist erforderlich');
return;
}
try {
setLoading(true);
setError(null);
let cityStats = {};
if (cityForm.cityStats.trim()) {
try {
cityStats = JSON.parse(cityForm.cityStats);
} catch (e) {
setError('Ungültiges JSON-Format für Stadt-Statistiken');
setLoading(false);
return;
}
}
const response = await fetch('https://vollidioten.ceraticsoft.de/api/admin/cities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: cityForm.name.trim(),
description: cityForm.description.trim() || undefined,
mayor: cityForm.mayor.trim() || undefined,
establishedYear: cityForm.establishedYear.trim() || undefined,
cityStats: cityStats
})
});
if (response.ok) {
const cityData = await response.json();
const cityId = cityData.cityId;
// Upload banner if provided
if (cityForm.bannerImageId) {
try {
const bannerFormData = new FormData();
// We need to get the file from the input, but since we don't have it stored,
// we'll skip this for now and show a message
console.log('Banner upload would happen here for city:', cityId);
} catch (err) {
console.error('Banner upload failed:', err);
}
}
// Upload logo if provided
if (cityForm.logoImageId) {
try {
const logoFormData = new FormData();
// We need to get the file from the input, but since we don't have it stored,
// we'll skip this for now and show a message
console.log('Logo upload would happen here for city:', cityId);
} catch (err) {
console.error('Logo upload failed:', err);
}
}
alert('Stadt erfolgreich erstellt! Bilder können nach der Erstellung im Bearbeitungsmodus hochgeladen werden.');
setCityForm({
name: '',
description: '',
mayor: '',
establishedYear: '',
cityStats: JSON.stringify({
taxRate: 5.0,
biome: 'Ebene',
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
}, null, 2)
});
loadCities(); // Reload to show new city
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Erstellen');
}
} catch (err) {
console.error('Error creating city:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
}}
disabled={loading || !cityForm.name.trim()}
className="w-full bg-green-500 hover:bg-green-600 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-medium disabled:cursor-not-allowed"
>
{loading ? 'Erstelle...' : 'Stadt erstellen'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit City Modal */}
{editingCity && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl max-h-[90vh] overflow-y-auto">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Edit className="w-5 h-5 text-blue-400" />
Stadt bearbeiten
</h3>
<button
onClick={() => setEditingCity(null)}
className="text-textMuted hover:text-white text-xl leading-none"
>
×
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-textMain mb-1">Stadtname *</label>
<input
type="text"
value={editCityForm.name}
onChange={(e) => setEditCityForm({...editCityForm, name: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Bürgermeister</label>
<input
type="text"
value={editCityForm.mayor}
onChange={(e) => setEditCityForm({...editCityForm, mayor: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Gründungsjahr</label>
<input
type="text"
value={editCityForm.establishedYear}
onChange={(e) => setEditCityForm({...editCityForm, establishedYear: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Beschreibung</label>
<textarea
value={editCityForm.description}
onChange={(e) => setEditCityForm({...editCityForm, description: e.target.value})}
className="w-full h-20 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Stadt-Statistiken (JSON)</label>
<textarea
value={editCityForm.cityStats}
onChange={(e) => setEditCityForm({...editCityForm, cityStats: e.target.value})}
className="w-full h-24 bg-[#0b0b0d] border border-border rounded p-2 text-sm font-mono text-xs"
/>
</div>
</div>
{/* Current Images */}
{(editingCity.bannerUrl || editingCity.logoUrl) && (
<div className="border-t border-border pt-4">
<h4 className="text-sm font-medium text-textMain mb-3">Aktuelle Bilder</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{editingCity.bannerUrl && (
<div className="bg-surfaceHighlight/30 border border-border rounded p-3">
<p className="text-xs text-textMuted mb-2">Banner</p>
<img
src={editingCity.bannerUrl}
alt="Banner"
className="w-full h-16 object-cover rounded border border-white/10"
/>
</div>
)}
{editingCity.logoUrl && (
<div className="bg-surfaceHighlight/30 border border-border rounded p-3">
<p className="text-xs text-textMuted mb-2">Logo</p>
<img
src={editingCity.logoUrl}
alt="Logo"
className="w-12 h-12 object-cover rounded border border-white/10"
/>
</div>
)}
</div>
</div>
)}
{/* Image Uploads */}
<div className="border-t border-border pt-4 space-y-4">
<h4 className="text-sm font-medium text-textMain">Bilder ändern</h4>
<div>
<label className="block text-sm font-medium text-textMain mb-2">Banner-Bild</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
setLoading(true);
const formData = new FormData();
formData.append('banner', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${editingCity.id}/banner/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
alert('Banner erfolgreich aktualisiert!');
loadCities(); // Reload to show updated image
} else {
setError('Fehler beim Hochladen des Banners');
}
} catch (err) {
console.error('Error uploading banner:', err);
setError('Netzwerkfehler beim Banner-Upload');
} finally {
setLoading(false);
}
}
}}
className="hidden"
id="edit-city-banner-upload"
disabled={loading}
/>
<label htmlFor="edit-city-banner-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-accentInfo/20 rounded flex items-center justify-center">
<Icons.Map className="w-4 h-4 text-accentInfo" />
</div>
<p className="text-xs text-textMain font-medium">
{loading ? 'Lädt...' : 'Banner ersetzen'}
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 5MB</p>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-2">Logo-Bild</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
setLoading(true);
const formData = new FormData();
formData.append('logo', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${editingCity.id}/logo/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
alert('Logo erfolgreich aktualisiert!');
loadCities(); // Reload to show updated image
} else {
setError('Fehler beim Hochladen des Logos');
}
} catch (err) {
console.error('Error uploading logo:', err);
setError('Netzwerkfehler beim Logo-Upload');
} finally {
setLoading(false);
}
}
}}
className="hidden"
id="edit-city-logo-upload"
disabled={loading}
/>
<label htmlFor="edit-city-logo-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-green-500/20 rounded flex items-center justify-center">
<Icons.Layers className="w-4 h-4 text-green-400" />
</div>
<p className="text-xs text-textMain font-medium">
{loading ? 'Lädt...' : 'Logo ersetzen'}
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 2MB Quadratisch bevorzugt</p>
</label>
</div>
</div>
</div>
<div className="flex gap-2 pt-2 border-t border-border">
<button
onClick={async () => {
try {
setLoading(true);
let cityStats = {};
if (editCityForm.cityStats.trim()) {
cityStats = JSON.parse(editCityForm.cityStats);
}
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${editingCity.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: editCityForm.name.trim(),
description: editCityForm.description.trim() || undefined,
mayor: editCityForm.mayor.trim() || undefined,
establishedYear: editCityForm.establishedYear.trim() || undefined,
cityStats: cityStats
})
});
if (response.ok) {
alert('Stadt erfolgreich aktualisiert!');
setEditingCity(null);
loadCities();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Aktualisieren');
}
} catch (err) {
console.error('Error updating city:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
}}
disabled={loading}
className="flex-1 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white px-3 py-2 rounded text-sm font-medium"
>
{loading ? '...' : 'Speichern'}
</button>
<button
onClick={() => setEditingCity(null)}
className="flex-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded text-sm"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
)}
{/* Shop Management Modal */}
<ShopManagementModal
isOpen={shopModalState.isOpen}

View File

@@ -9,6 +9,7 @@ import EditModal from '../components/EditModal';
import ShopManagementModal from '../components/ShopManagementModal';
import EmployeeManagementModal from '../components/EmployeeManagementModal';
import BannerManagementModal from '../components/BannerManagementModal';
import LogoManagementModal from '../components/LogoManagementModal';
import GalleryManagementModal from '../components/GalleryManagementModal';
import DeleteProjectModal from '../components/DeleteProjectModal';
@@ -26,6 +27,7 @@ const ProjectProfile: React.FC = () => {
const [isShopModalOpen, setIsShopModalOpen] = useState(false);
const [isEmployeeModalOpen, setIsEmployeeModalOpen] = useState(false);
const [isBannerModalOpen, setIsBannerModalOpen] = useState(false);
const [isLogoModalOpen, setIsLogoModalOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -147,9 +149,17 @@ const ProjectProfile: React.FC = () => {
className={`flex items-center gap-2 ${ownerPlayer ? 'cursor-pointer hover:text-white transition-colors group/owner' : ''}`}
onClick={() => ownerPlayer && onSelectPlayer(ownerPlayer.uuid)}
>
{project.logoUrl ? (
<img
src={project.logoUrl}
alt={`${project.title} Logo`}
className="w-5 h-5 rounded-full border border-white/10 object-cover group-hover/owner:border-accentInfo transition-colors"
/>
) : (
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-[10px] border border-white/10 font-bold group-hover/owner:border-accentInfo group-hover/owner:text-accentInfo transition-colors">
{project.owner.charAt(0)}
</div>
)}
Inhaber: <span className="text-white font-medium group-hover/owner:underline decoration-accentInfo/50 underline-offset-4">{project.owner}</span>
</span>
{project.foundedDate && (
@@ -218,9 +228,9 @@ const ProjectProfile: React.FC = () => {
<Icons.Layers className="w-4 h-4 text-accentInfo" /> Portfolio
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{project.gallery.map((url, idx) => (
{project.gallery.map((image, idx) => (
<div key={idx} className="rounded-xl overflow-hidden aspect-video border border-border group relative">
<img src={url} alt={`Portfolio ${idx}`} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<img src={image.url} alt={`Portfolio ${idx}`} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
))}
@@ -405,6 +415,20 @@ const ProjectProfile: React.FC = () => {
</button>
</div>
{/* Logo Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Logo ändern</h4>
<p className="text-xs text-textMuted mb-4">
Setzen Sie ein Logo für Ihr Unternehmen.
</p>
<button
onClick={() => setIsLogoModalOpen(true)}
className="w-full bg-green-500 hover:bg-green-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Logo bearbeiten
</button>
</div>
{/* Gallery Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Galerie verwalten</h4>
@@ -630,6 +654,17 @@ const ProjectProfile: React.FC = () => {
}}
/>
<LogoManagementModal
isOpen={isLogoModalOpen}
onClose={() => setIsLogoModalOpen(false)}
projectId={project.id}
currentLogoUrl={project.logoUrl || ''}
onUpdate={() => {
// Refresh project data
console.log('Logo updated, refreshing project data...');
}}
/>
<GalleryManagementModal
isOpen={isGalleryModalOpen}
onClose={() => setIsGalleryModalOpen(false)}

View File

@@ -60,9 +60,22 @@ const VentureCard = ({ project, onClick }: { project: Project, onClick?: () => v
<StatusBadge status={project.status} hiring={project.hiring} />
</div>
<h3 className="text-lg font-bold text-textMain mb-1 group-hover:text-accentInfo transition-colors relative z-10">
<div className="flex items-center gap-2 mb-1 relative z-10">
{project.logoUrl ? (
<img
src={project.logoUrl}
alt={`${project.title} Logo`}
className="w-12 h-12 rounded border border-white/10 object-cover"
/>
) : (
<div className="w-12 h-12 rounded bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-xs font-bold text-textMuted">
{project.title.charAt(0)}
</div>
)}
<h3 className="text-lg font-bold text-textMain group-hover:text-accentInfo transition-colors">
{project.title}
</h3>
</div>
<div className="flex items-center gap-2 mb-4 text-xs text-textMuted relative z-10">
<span>Inhaber</span>

View File

@@ -59,6 +59,11 @@ export interface ShopItem {
materialsRequired?: string; // e.g. "Customer provides Stone"
}
export interface GalleryImage {
id: string;
url: string;
}
export interface Project {
id: string;
title: string;
@@ -72,8 +77,9 @@ export interface Project {
foundedDate?: string;
associatedOrgId?: string; // Links this project to a city or guild
shopCatalog?: ShopItem[];
gallery?: string[];
gallery?: GalleryImage[];
bannerUrl?: string;
logoUrl?: string;
}
export interface DiscordUser {