mirror of
https://github.com/ceratic/project_vollidioten_website.git
synced 2026-05-14 00:16:47 +02:00
feat: add LogoManagementModal component for logo upload and management
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
1231
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
153
components/LogoManagementModal.tsx
Normal file
153
components/LogoManagementModal.tsx
Normal 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">×</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;
|
||||
@@ -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
|
||||
|
||||
663
pages/Admin.tsx
663
pages/Admin.tsx
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
8
types.ts
8
types.ts
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user